← back to Quadrille Showroom
High-end versions: add V12 Gallery Noir + V16 Art-Deco Brass/Marble + V21 Garcia Room proto scenes (PBR/PMREM/ACES, real China Seas textures, 0 errors)
fbe4548b3238a58aa73a4ca33714a33cd32cd976 · 2026-06-29 06:07:12 -0700 · Steve
Files touched
A public/proto/v12-gallery-noir.htmlA public/proto/v16-artdeco-brass.htmlA public/proto/v21-garcia-room.html
Diff
commit fbe4548b3238a58aa73a4ca33714a33cd32cd976
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jun 29 06:07:12 2026 -0700
High-end versions: add V12 Gallery Noir + V16 Art-Deco Brass/Marble + V21 Garcia Room proto scenes (PBR/PMREM/ACES, real China Seas textures, 0 errors)
---
public/proto/v12-gallery-noir.html | 616 ++++++++++++++++++++++++++++++
public/proto/v16-artdeco-brass.html | 589 +++++++++++++++++++++++++++++
public/proto/v21-garcia-room.html | 735 ++++++++++++++++++++++++++++++++++++
3 files changed, 1940 insertions(+)
diff --git a/public/proto/v12-gallery-noir.html b/public/proto/v12-gallery-noir.html
new file mode 100644
index 0000000..2b534ae
--- /dev/null
+++ b/public/proto/v12-gallery-noir.html
@@ -0,0 +1,616 @@
+<!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>GALLERY NOIR · China Seas</title>
+<style>
+ :root { --gold:#c9a96e; --paper:#efe9dc; }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; overflow:hidden; background:#050505; 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(3,3,3,.82) 0%, rgba(3,3,3,0) 100%); }
+ .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+ color:#9a8b6b; font-weight:600; }
+ .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
+ font-weight:400; letter-spacing:.12em; line-height:1; margin-top:9px; color:#f3eddf; }
+ .masthead h1 b { font-weight:700; color:#e8cf97; }
+ .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.05em; color:#857d6e;
+ 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:#f3eddf;
+ text-shadow:0 2px 18px rgba(0,0,0,.9); }
+ #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#b59a63; }
+ #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#9b9484; }
+ #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+ border:1px solid rgba(201,169,110,.55); border-radius:28px; color:#e8cf97; font-size:10.5px;
+ letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+ background:rgba(201,169,110,.06); transition:background .15s, transform .15s; }
+ #caption a.cta:hover { background:rgba(201,169,110,.18); 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(220,210,190,.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:#9a8b6b; background:rgba(8,8,8,.6); border:1px solid rgba(201,169,110,.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:#050505; transition:opacity .9s; }
+ #boot .ring { width:44px; height:44px; border-radius:50%;
+ border:2px solid rgba(201,169,110,.18); border-top-color:#c9a96e; animation:spin 1s linear infinite; }
+ #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#7d745f; }
+ @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 Noir Gallery</div>
+ <h1>GALLERY <b>NOIR</b></h1>
+ <div class="sub">China Seas, hung in a black-box gallery. One board steps into the light at a time —
+ full pattern, edge to edge. 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">Dimming the gallery…</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 src="https://unpkg.com/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.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.04;
+ if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ renderer.setClearColor(0x040404, 1);
+ app.appendChild(renderer.domElement);
+ const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x050505);
+ scene.fog = new THREE.FogExp2(0x040404, 0.030);
+
+ 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) — so dark metals & gloss reflect
+ // -------------------------------------------------------------------------
+ (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 — deep chiaroscuro: low ambient, cool fill, warm rim, plus a
+ // single warm SpotLight that follows the featured board.
+ // -------------------------------------------------------------------------
+ scene.add(new THREE.AmbientLight(0x161a22, 0.42));
+
+ const fill = new THREE.DirectionalLight(0x6f7d96, 0.22); // cool fill from left
+ fill.position.set(-7, 6, 6); scene.add(fill);
+
+ const rim = new THREE.DirectionalLight(0xffd9a0, 0.30); // warm rim from behind-right
+ rim.position.set(6, 4, -8); scene.add(rim);
+
+ const key = new THREE.DirectionalLight(0xfff1da, 0.30); // soft 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);
+
+ // the hero spotlight — warm, tight, casts the gallery shadow on the focused board
+ const spot = new THREE.SpotLight(0xffe6bd, 0, 16, Math.PI/7.5, 0.45, 1.4);
+ 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 = 22;
+ spot.shadow.bias = -0.0005; spot.shadow.radius = 5;
+ scene.add(spot);
+ const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
+ spot.target = spotTarget;
+
+ // -------------------------------------------------------------------------
+ // ROOM — matte black walls + polished dark-concrete floor (reflective)
+ // -------------------------------------------------------------------------
+ const FLOOR_Y = 0;
+ (function buildRoom(){
+ // back wall (the gallery void behind the bank)
+ const wallMat = new THREE.MeshStandardMaterial({ color:0x0a0a0b, roughness:0.94, metalness:0.0 });
+ const back = new THREE.Mesh(new THREE.PlaneGeometry(60, 24), wallMat);
+ back.position.set(0, 6, -7.5); back.receiveShadow = true; scene.add(back);
+
+ const sideMat = new THREE.MeshStandardMaterial({ color:0x080809, roughness:0.96, metalness:0.0 });
+ const left = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), sideMat);
+ left.position.set(-14, 6, 2); left.rotation.y = Math.PI/2; scene.add(left);
+ const right = left.clone(); right.position.x = 14; right.rotation.y = -Math.PI/2; scene.add(right);
+
+ // ceiling (very dark, no light bounce)
+ const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 40),
+ new THREE.MeshStandardMaterial({ color:0x060607, roughness:1.0 }));
+ ceil.position.set(0, 13, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
+
+ // POLISHED DARK CONCRETE FLOOR — clearcoat physical material gives soft
+ // env reflections; a procedural roughness texture keeps it from being a
+ // mirror. The mirrored board "reflection" group below adds the contact glow.
+ const floorMat = new THREE.MeshPhysicalMaterial({
+ color:0x0c0c0e, roughness:0.34, metalness:0.0,
+ clearcoat:0.85, clearcoatRoughness:0.42, envMapIntensity:0.5
+ });
+ floorMat.roughnessMap = makeConcreteRoughness();
+ floorMat.roughnessMap.wrapS = floorMat.roughnessMap.wrapT = THREE.RepeatWrapping;
+ floorMat.roughnessMap.repeat.set(8, 8);
+ 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);
+ })();
+
+ // procedural subtle concrete roughness (canvas → texture, $0 local)
+ function makeConcreteRoughness(){
+ const c = document.createElement("canvas"); c.width = c.height = 256;
+ const x = c.getContext("2d");
+ x.fillStyle = "#777"; 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()*70)|0; d[i]=d[i+1]=d[i+2]=110+n; }
+ x.putImageData(img,0,0);
+ const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+ }
+
+ // -------------------------------------------------------------------------
+ // THE WING BANK — thin dark-framed boards, ~2" apart on an arc, side swivel.
+ // Frames use InstancedMesh (one geometry, one material, 50 instances).
+ // -------------------------------------------------------------------------
+ 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; // which board is featured (center, swung open)
+
+ // shared geometries / materials
+ const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+ // dark bronze frame material
+ const frameMat = new THREE.MeshStandardMaterial({ color:0x231d16, roughness:0.5, metalness:0.85, envMapIntensity:0.9 });
+ // placeholder face (warm gray) until the real texture loads
+ function placeholderFace(){
+ return new THREE.MeshPhysicalMaterial({ color:0x171615, roughness:0.62, metalness:0.0, envMapIntensity:0.4 });
+ }
+
+ // build a single board: a Group pivoting on its LEFT edge (side swivel) so it
+ // can swing face-on like the reference. Group origin = left hinge.
+ function buildBoard(idx){
+ const g = new THREE.Group();
+ // backing board (very thin) — gives the face something to sit on
+ 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:0x100f0e, 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);
+
+ // pattern face
+ 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 frame bars (built per board but light — could be instanced; kept
+ // grouped so they swivel with the board. Frame instancing handled separately
+ // would break the hinge transform, so per-board bars are correct here.)
+ 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.0, 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, lift:0 };
+ }
+
+ // -------------------------------------------------------------------------
+ // FLOOR REFLECTION — a mirrored, dimmed clone of the featured board's face,
+ // sitting below the floor line → soft contact reflection (cheap, 1 mesh).
+ // -------------------------------------------------------------------------
+ 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; // mirror vertically
+ reflMesh.renderOrder = 1;
+ scene.add(reflMesh);
+
+ // -------------------------------------------------------------------------
+ // TEXTURE LOADER — lazy: only load the featured board + a window of neighbours
+ // -------------------------------------------------------------------------
+ const texCache = new Map(); // url -> THREE.Texture
+ 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;
+ // 27" pattern across a ~30" board face → show roughly one full repeat,
+ // slightly less so the full motif reads clean edge-to-edge.
+ 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.58, 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;
+ });
+ }
+
+ // window-load textures around the focused board so we never hold 50 at once
+ 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 — closed boards line up along X on a gentle concave arc facing the
+ // viewer; the focused board slides to center (x≈0) and swivels open.
+ // -------------------------------------------------------------------------
+ function closedX(i){ return (i - focusIdx) * PITCH; } // relative slot
+ function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; } // very gentle concavity
+
+ // -------------------------------------------------------------------------
+ // INTERACTION
+ // -------------------------------------------------------------------------
+ let pan = 0, targetFocus = 0; // targetFocus is a float for smooth carousel travel
+ 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]);
+ }
+
+ // drag to swing the bank; wheel to step
+ 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");
+ }
+
+ // -------------------------------------------------------------------------
+ // POST-PROCESSING — tasteful bloom on the warm highlights
+ // -------------------------------------------------------------------------
+ let composer=null, bloomPass=null;
+ function buildComposer(){
+ if(!THREE.EffectComposer || !THREE.UnrealBloomPass) return false;
+ composer = new THREE.EffectComposer(renderer);
+ composer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+ composer.setSize(W(), H());
+ composer.addPass(new THREE.RenderPass(scene, camera));
+ bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(W(), H()), 0.42, 0.7, 0.86);
+ composer.addPass(bloomPass);
+ return true;
+ }
+ const HAS_BLOOM = buildComposer();
+
+ // -------------------------------------------------------------------------
+ // RESIZE
+ // -------------------------------------------------------------------------
+ window.addEventListener("resize", ()=>{
+ camera.aspect = W()/H(); camera.updateProjectionMatrix();
+ renderer.setSize(W(), H());
+ if(composer) composer.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;
+
+ // smooth carousel travel toward target
+ 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; // signed slot offset from center
+ const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1); // 1 at center → 0 a slot away
+
+ // swivel: the centered board faces fully on (0); neighbours rake away so
+ // they read as thin slivers beside the open face (per the v3 reference).
+ 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;
+
+ // The group pivots on its LEFT edge, so the face center sits at
+ // (groupX + (BOARD_W/2)·cos(swivel)). We want that face center at the slot
+ // x = rel·PITCH, so solve groupX = slotX − (BOARD_W/2)·cos(swivel).
+ const slotX = rel * PITCH;
+ const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+ const targetZ = arcZ(slotX) + openAmt*1.55; // glides forward when centered
+
+ 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;
+
+ // CHIAROSCURO — fade the face/frame brightness with distance from center so
+ // only the featured board sits fully in the warm light; neighbours recede
+ // into the dark. Driven on the material so it survives the swivel.
+ const litTarget = 0.18 + 0.82*Math.pow(openAmt, 1.4);
+ 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); }
+ b.face.children; // (no-op keep)
+
+ // depth-cull faces far off-center for fewer pixels drawn (still in scene)
+ const farOff = Math.abs(rel) > 7;
+ b.group.visible = !farOff;
+ }
+
+ // spotlight + reflection track the featured board
+ const front = boards[focusIdx];
+ if(front){
+ const fx = front.group.position.x + BOARD_W/2;
+ spotTarget.position.set(fx, BOARD_H*0.5, front.group.position.z);
+ spot.position.set(fx, 6.4, front.group.position.z + 5.2);
+ spot.intensity += ((HAS_PRODUCTS?7.5:0) - spot.intensity)*Math.min(1,dt*5);
+
+ // reflection: clone featured face material, place mirrored below floor
+ if(front.faceMat && front.faceMat.map){
+ reflMat.map = front.faceMat.map;
+ reflMat.opacity += (0.16 - 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);
+ }
+ }
+
+ if(HAS_BLOOM && composer) composer.render();
+ else 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.__GN_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;
+
+ // build boards (one per product, capped at 50)
+ for(let i=0;i<products.length;i++){
+ const b = buildBoard(i);
+ b.data = products[i];
+ // seed initial transform near final so the first frame isn't a stampede
+ b.group.position.set((i-0)*PITCH, 0, BANK_Z);
+ boards.push(b);
+ }
+ bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+ // open in the middle of the bank for a full-looking first frame
+ focusIdx = Math.min(2, boards.length-1);
+ pan = targetFocus = focusIdx;
+ refreshTextureWindow();
+
+ // wait for the featured texture before revealing (so it's never a void)
+ const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+ await loadTexture(featUrl).catch(()=>null);
+ ensureBoardTexture(boards[focusIdx]);
+ showCaption(boards[focusIdx]);
+
+ // expose probe for headless verification
+ window.__GN__ = {
+ boards: ()=>boards.length,
+ textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+ focus: ()=>focusIdx,
+ fps: ()=>window.__GN_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":"V12","label":"V12 — Gallery Noir","elements":[
+ {"n":1,"label":"Black-box gallery — matte walls, deep chiaroscuro"},
+ {"n":2,"label":"Thin dark-bronze framed boards, ~2\" apart on a side swivel"},
+ {"n":3,"label":"Warm spotlight follows the featured board"},
+ {"n":4,"label":"Polished dark-concrete floor with contact reflection"},
+ {"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/v16-artdeco-brass.html b/public/proto/v16-artdeco-brass.html
new file mode 100644
index 0000000..5e7379a
--- /dev/null
+++ b/public/proto/v16-artdeco-brass.html
@@ -0,0 +1,589 @@
+<!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>ART-DECO BRASS & MARBLE — China Seas</title>
+<style>
+ :root { --brass:#c9a24b; --ink:#120e09; --paper:#f3ead4; }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; overflow:hidden; background:#0b0906; color:var(--paper);
+ font-family:"Hoefler Text",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:12; padding:20px 30px 46px;
+ pointer-events:none; text-align:center;
+ background:linear-gradient(180deg, rgba(11,9,6,.82) 0%, rgba(11,9,6,0) 100%); }
+ .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+ color:#d8be7e; font-weight:600; }
+ .masthead h1 { font-size:clamp(24px,4.4vw,46px); font-weight:400; letter-spacing:.14em;
+ line-height:1; margin-top:8px; color:#f3ead4;
+ text-shadow:0 2px 18px rgba(0,0,0,.6); }
+ .masthead h1 b { font-weight:700; color:#e7c878; }
+ .masthead .sub { margin-top:9px; font-size:12px; letter-spacing:.16em; text-transform:uppercase;
+ color:#b09a6a; }
+
+ /* ---- now-viewing plate (bottom centre) ---- */
+ .plate { position:fixed; left:50%; bottom:30px; transform:translateX(-50%); z-index:12;
+ text-align:center; pointer-events:none; max-width:80vw;
+ text-shadow:0 2px 14px rgba(0,0,0,.7); transition:opacity .4s; }
+ .plate .pn { font-size:20px; letter-spacing:.04em; color:#f3ead4; }
+ .plate .meta { margin-top:5px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:#c9a24b; }
+
+ .hint { position:fixed; left:50%; bottom:84px; transform:translateX(-50%); z-index:11;
+ font-size:10.5px; letter-spacing:.30em; text-transform:uppercase; color:rgba(243,234,212,.5);
+ pointer-events:none; transition:opacity .8s; }
+
+ /* ---- fps readout ---- */
+ #fps { position:fixed; left:14px; bottom:12px; z-index:14;
+ font-family:"SF Mono",Menlo,monospace; font-size:11px; letter-spacing:.08em;
+ color:#d8be7e; background:rgba(11,9,6,.55); border:1px solid rgba(201,162,75,.4);
+ padding:4px 9px; border-radius:6px; cursor:pointer; pointer-events:auto;
+ -webkit-user-select:none; user-select:none; }
+ #fps.hidden { opacity:0; pointer-events:none; }
+
+ /* ---- detail spec card ---- */
+ #detail { position:fixed; z-index:13; right:32px; top:50%; transform:translateY(-50%) translateX(40px);
+ width:min(322px,40vw); background:rgba(18,14,9,.78); backdrop-filter:blur(16px);
+ -webkit-backdrop-filter:blur(16px); border:1px solid rgba(201,162,75,.42);
+ border-radius:6px; padding:24px; opacity:0; pointer-events:none;
+ box-shadow:0 24px 60px -16px rgba(0,0,0,.8);
+ transition:opacity .4s ease, transform .45s cubic-bezier(.2,.8,.2,1); }
+ #detail.show { opacity:1; transform:translateY(-50%) translateX(0); pointer-events:auto; }
+ #detail .pname { font-size:21px; line-height:1.14; color:#f3ead4; padding-right:18px; }
+ #detail .pcoll { margin-top:5px; font-size:10.5px; letter-spacing:.26em; text-transform:uppercase; color:#c9a24b; }
+ #detail .rule { height:1px; margin:16px 0 4px; background:linear-gradient(90deg,transparent,rgba(201,162,75,.6),transparent); }
+ #detail .specrow { display:flex; justify-content:space-between; padding:8px 0;
+ border-bottom:1px solid rgba(201,162,75,.14); font-size:12.5px; }
+ #detail .specrow .k { color:#9c8a63; letter-spacing:.03em; }
+ #detail .specrow .v { color:#efe6d0; text-align:right; }
+ #detail a.cta { display:block; margin-top:18px; text-align:center; padding:12px;
+ background:linear-gradient(180deg,#e3cb95,#c19a44); color:#1a1206; font-size:11px;
+ letter-spacing:.22em; text-transform:uppercase; font-weight:700; text-decoration:none;
+ border-radius:3px; transition:transform .15s; font-family:Georgia,serif; }
+ #detail a.cta:hover { transform:translateY(-1px); }
+ #detail .closex { position:absolute; top:12px; right:14px; font-size:20px; color:#c9a24b;
+ cursor:pointer; line-height:1; }
+ #detail .closex:hover { color:#f3ead4; }
+
+ /* ---- boot ---- */
+ #boot { position:fixed; inset:0; z-index:40; display:flex; flex-direction:column;
+ align-items:center; justify-content:center; background:#0b0906; transition:opacity .8s; }
+ #boot .ring { width:42px; height:42px; border-radius:50%;
+ border:2px solid rgba(201,162,75,.2); border-top-color:#e7c878; animation:spin 1s linear infinite; }
+ #boot .lbl { margin-top:18px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#9c8a63; }
+ @keyframes spin { to { transform:rotate(360deg); } }
+
+ @media (max-width:760px){
+ #detail { right:12px; left:12px; width:auto; top:auto; bottom:96px; transform:translateY(20px); }
+ #detail.show { transform:translateY(0); }
+ .masthead .sub { display:none; }
+ }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+ <div class="kicker">Designer Wallcoverings</div>
+ <h1>ART <b>DECO</b> SALON</h1>
+ <div class="sub">Brass & Marble · China Seas</div>
+</div>
+
+<div class="plate" id="plate"><div class="pn" id="p_name">—</div><div class="meta" id="p_meta"></div></div>
+<div class="hint" id="hint">Click a board to bring it to centre</div>
+<div id="fps">FPS —</div>
+
+<div id="detail">
+ <div class="closex" id="d_close">×</div>
+ <div class="pname" id="d_name">—</div>
+ <div class="pcoll" id="d_coll">China Seas</div>
+ <div class="rule"></div>
+ <div class="specrow"><span class="k">Dimensions</span><span class="v" id="d_dim">27" × 27"</span></div>
+ <div class="specrow"><span class="k">Repeat</span><span class="v" id="d_rep">—</span></div>
+ <div class="specrow"><span class="k">Match</span><span class="v" id="d_match">—</span></div>
+ <div class="specrow"><span class="k">Finish</span><span class="v" id="d_finish">—</span></div>
+ <div class="specrow"><span class="k">Material</span><span class="v" id="d_mat">—</span></div>
+ <a class="cta" id="d_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Polishing the brass…</div></div>
+
+<script src="./three.min.js"></script>
+<script>
+"use strict";
+(function () {
+ const API = "/api/showroom/products?limit=50";
+ const bootlbl = document.getElementById("bootlbl");
+
+ // ----------------------------------------------------------------------------
+ // RENDERER — full high-end baseline
+ // ----------------------------------------------------------------------------
+ const app = document.getElementById("app");
+ const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
+ else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.toneMappingExposure = 1.06;
+ if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ app.appendChild(renderer.domElement);
+ const MAXANISO = renderer.capabilities.getMaxAnisotropy();
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x14100a);
+ scene.fog = new THREE.Fog(0x14100a, 9, 26);
+
+ const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 120);
+ // sit close & at chest height so the centred 6' board fills the frame, with the
+ // slivers raking off to either side and the marble floor sweeping to the camera.
+ camera.position.set(-0.55, 1.0, -0.15);
+ camera.lookAt(-0.4, 1.0, -3.4);
+
+ // ----------------------------------------------------------------------------
+ // PMREM ENVIRONMENT — procedural Art-Deco "salon" equirect so brass reflects.
+ // A warm gradient with several bright soft sources + a crisp horizon band, so a
+ // metalness~1 surface shows specular highlights (reads as polished metal, not
+ // flat yellow) and the marble clearcoat catches reflections.
+ // ----------------------------------------------------------------------------
+ function buildEnvTexture(){
+ const c = document.createElement("canvas");
+ c.width = 1024; c.height = 512;
+ const g = c.getContext("2d");
+ // vertical gradient: warm amber ceiling glow → deep bronze floor
+ const grd = g.createLinearGradient(0, 0, 0, 512);
+ grd.addColorStop(0.00, "#3a2c14");
+ grd.addColorStop(0.34, "#6e5526");
+ grd.addColorStop(0.50, "#caa763"); // bright horizon (key wash)
+ grd.addColorStop(0.62, "#5b4521");
+ grd.addColorStop(1.00, "#1c150c");
+ g.fillStyle = grd; g.fillRect(0, 0, 1024, 512);
+ // bright soft "light fixtures" — these become the brass highlights
+ function lamp(x, y, r, col){
+ const lg = g.createRadialGradient(x, y, 0, x, y, r);
+ lg.addColorStop(0, col); lg.addColorStop(1, "rgba(0,0,0,0)");
+ g.globalCompositeOperation = "lighter";
+ g.fillStyle = lg; g.beginPath(); g.arc(x, y, r, 0, Math.PI*2); g.fill();
+ g.globalCompositeOperation = "source-over";
+ }
+ lamp(180, 150, 150, "rgba(255,244,214,0.95)"); // warm key
+ lamp(840, 170, 150, "rgba(255,236,196,0.85)"); // warm fill
+ lamp(512, 90, 120, "rgba(255,250,235,0.9)"); // overhead
+ lamp(512, 300, 220, "rgba(255,221,160,0.55)"); // horizon glow
+ lamp(40, 300, 90, "rgba(214,230,255,0.45)"); // cool rim (left)
+ lamp(990, 300, 90, "rgba(214,230,255,0.4)"); // cool rim (right)
+ // a crisp bright horizon line gives metals a sharp reflected edge
+ g.fillStyle = "rgba(255,243,214,0.5)";
+ g.fillRect(0, 252, 1024, 6);
+ const tex = new THREE.CanvasTexture(c);
+ tex.mapping = THREE.EquirectangularReflectionMapping;
+ if ("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+ else if ("encoding" in tex) tex.encoding = THREE.sRGBEncoding;
+ return tex;
+ }
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ pmrem.compileEquirectangularShader();
+ const envTex = buildEnvTexture();
+ const envMap = pmrem.fromEquirectangular(envTex).texture;
+ scene.environment = envMap;
+ envTex.dispose();
+
+ // ----------------------------------------------------------------------------
+ // LIGHTING — key + fill + rim, warm, with a soft key shadow
+ // ----------------------------------------------------------------------------
+ scene.add(new THREE.HemisphereLight(0xfff0d4, 0x2a2114, 0.55));
+ const key = new THREE.DirectionalLight(0xfff1d6, 2.4);
+ key.position.set(-5.5, 8.5, 6.5);
+ key.castShadow = true;
+ key.shadow.mapSize.set(2048, 2048);
+ key.shadow.camera.near = 1; key.shadow.camera.far = 30;
+ key.shadow.camera.left = -8; key.shadow.camera.right = 8;
+ key.shadow.camera.top = 8; key.shadow.camera.bottom = -4;
+ key.shadow.bias = -0.0004;
+ scene.add(key);
+ const fill = new THREE.DirectionalLight(0xffe2b4, 0.55);
+ fill.position.set(6, 4, 7); scene.add(fill);
+ const rim = new THREE.DirectionalLight(0xcfe0ff, 0.8);
+ rim.position.set(0, 5, -8); scene.add(rim);
+ // warm spot pooling onto the centre (focal) board
+ const spot = new THREE.SpotLight(0xfff0d0, 2.2, 22, Math.PI/6.5, 0.5, 1.2);
+ spot.position.set(0, 7.5, 6.5); spot.target.position.set(0, 1.7, 0);
+ scene.add(spot); scene.add(spot.target);
+
+ // ----------------------------------------------------------------------------
+ // MARBLE FLOOR — physical clearcoat, env-mapped reflection
+ // ----------------------------------------------------------------------------
+ function marbleTexture(){
+ const c = document.createElement("canvas"); c.width = c.height = 1024;
+ const g = c.getContext("2d");
+ g.fillStyle = "#ece3d2"; g.fillRect(0,0,1024,1024);
+ // soft warm clouding
+ for(let i=0;i<60;i++){
+ const x=Math.random()*1024, y=Math.random()*1024, r=80+Math.random()*240;
+ const lg=g.createRadialGradient(x,y,0,x,y,r);
+ const a=0.05+Math.random()*0.08;
+ lg.addColorStop(0,`rgba(196,178,150,${a})`); lg.addColorStop(1,"rgba(196,178,150,0)");
+ g.fillStyle=lg; g.beginPath(); g.arc(x,y,r,0,Math.PI*2); g.fill();
+ }
+ // grey/bronze veining
+ g.lineCap="round";
+ for(let i=0;i<26;i++){
+ g.strokeStyle = Math.random()<0.5 ? "rgba(120,108,86,0.30)" : "rgba(80,70,52,0.18)";
+ g.lineWidth = 0.6+Math.random()*2.2;
+ let x=Math.random()*1024, y=Math.random()*1024;
+ g.beginPath(); g.moveTo(x,y);
+ const steps=8+Math.floor(Math.random()*10), ang=Math.random()*Math.PI;
+ for(let s=0;s<steps;s++){ x+=Math.cos(ang)*(30+Math.random()*60)+(Math.random()-0.5)*70;
+ y+=Math.sin(ang)*(30+Math.random()*60)+(Math.random()-0.5)*70; g.lineTo(x,y); }
+ g.stroke();
+ }
+ const t=new THREE.CanvasTexture(c);
+ t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(3,3);
+ t.anisotropy=MAXANISO;
+ if("colorSpace" in t) t.colorSpace=THREE.SRGBColorSpace; else if("encoding" in t) t.encoding=THREE.sRGBEncoding;
+ return t;
+ }
+ const floorMat = new THREE.MeshPhysicalMaterial({
+ map: marbleTexture(), color:0xf2ebda, roughness:0.12, metalness:0.0,
+ clearcoat:1.0, clearcoatRoughness:0.06, envMapIntensity:1.1, reflectivity:0.6
+ });
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), floorMat);
+ floor.rotation.x = -Math.PI/2; floor.position.y = 0; floor.receiveShadow = true;
+ scene.add(floor);
+
+ // ----------------------------------------------------------------------------
+ // BACK WALL + Art-Deco fluted brass pilasters (symmetry)
+ // ----------------------------------------------------------------------------
+ const wallMat = new THREE.MeshStandardMaterial({ color:0x241b10, roughness:0.85, metalness:0.05 });
+ const backWall = new THREE.Mesh(new THREE.PlaneGeometry(60, 18), wallMat);
+ backWall.position.set(0, 9, -4.2); backWall.receiveShadow = true; scene.add(backWall);
+
+ // shared polished-brass material (metalness 1, low roughness → reflects env)
+ const brassMat = new THREE.MeshPhysicalMaterial({
+ color:0xe7c067, metalness:1.0, roughness:0.14, envMapIntensity:2.2,
+ clearcoat:0.5, clearcoatRoughness:0.14
+ });
+ const brassDark = new THREE.MeshPhysicalMaterial({
+ color:0xb08a3a, metalness:1.0, roughness:0.30, envMapIntensity:1.3
+ });
+
+ // symmetrical fluted pilasters flanking the bank
+ (function pilasters(){
+ const g = new THREE.CylinderGeometry(0.16, 0.16, 7.6, 20, 1);
+ [-5.6, 5.6].forEach(x=>{
+ const p = new THREE.Mesh(g, brassMat); p.position.set(x, 3.8, -3.9);
+ p.castShadow = true; scene.add(p);
+ const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.30,0.22,0.34,20), brassMat);
+ cap.position.set(x, 7.7, -3.9); scene.add(cap);
+ const base = new THREE.Mesh(new THREE.CylinderGeometry(0.26,0.32,0.30,20), brassMat);
+ base.position.set(x, 0.15, -3.9); scene.add(base);
+ });
+ // deco sunburst lintel above
+ const lintel = new THREE.Mesh(new THREE.BoxGeometry(11.6, 0.32, 0.3), brassMat);
+ lintel.position.set(0, 7.9, -3.85); lintel.castShadow = true; scene.add(lintel);
+ const rayMat = brassDark;
+ for(let i=0;i<13;i++){
+ const t=i/12, x=-5.2 + t*10.4;
+ const h=0.5 + Math.abs(Math.sin(t*Math.PI))*0.9;
+ const r=new THREE.Mesh(new THREE.BoxGeometry(0.05, h, 0.06), rayMat);
+ r.position.set(x, 8.3+h/2, -3.8); scene.add(r);
+ }
+ })();
+
+ // soft contact shadow under the bank
+ (function contactShadow(){
+ const cc=document.createElement("canvas"); cc.width=cc.height=256; const cg=cc.getContext("2d");
+ const grd=cg.createRadialGradient(128,128,10,128,128,128);
+ grd.addColorStop(0,"rgba(0,0,0,0.5)"); grd.addColorStop(0.6,"rgba(0,0,0,0.2)"); grd.addColorStop(1,"rgba(0,0,0,0)");
+ cg.fillStyle=grd; cg.fillRect(0,0,256,256);
+ const t=new THREE.CanvasTexture(cc);
+ const m=new THREE.Mesh(new THREE.PlaneGeometry(14,4),
+ new THREE.MeshBasicMaterial({ map:t, transparent:true, opacity:0.7, depthWrite:false,
+ blending:THREE.MultiplyBlending }));
+ m.rotation.x=-Math.PI/2; m.position.set(0,0.01,-1.6); scene.add(m);
+ })();
+
+ // ----------------------------------------------------------------------------
+ // THE WING BANK — brass-framed sample boards ~2" apart on a side swivel.
+ // Frames are an INSTANCED brass border (FPS budget); each board face is its own
+ // textured plane (China Seas). Click a board → carousel-shift to dead centre →
+ // swivel open to a CLEAN full-pattern face, no occluders.
+ // ----------------------------------------------------------------------------
+ const IN = 0.0254; // 1 inch in metres
+ const BOARD_W = 30 * IN; // 30" wide
+ const BOARD_H = 72 * IN; // 6' tall
+ const FRAME_T = 1.2 * IN; // thin brass frame
+ const GAP = 2 * IN; // 2" apart
+ const PITCH = BOARD_W + FRAME_T*2 + GAP;
+ const BOARD_Y = 0.06 + BOARD_H/2; // feet near floor
+ const BANK_Z = -3.4;
+
+ const NBOARDS_TARGET = 50;
+ const loader = new THREE.TextureLoader();
+ loader.crossOrigin = "anonymous";
+
+ // one shared geometry for board faces
+ const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+ // shared neutral face material while textures stream in
+ const placeholderMat = new THREE.MeshStandardMaterial({ color:0x2b2218, roughness:0.9 });
+
+ const boards = []; // { group, mesh, mat, data, slot, swivel(0..1) }
+ const bankGroup = new THREE.Group(); scene.add(bankGroup);
+ const pickables = [];
+
+ // Build the four-sided brass frame for a board as a small group of boxes.
+ function makeFrame(){
+ const grp = new THREE.Group();
+ const ow = BOARD_W + FRAME_T*2, oh = BOARD_H + FRAME_T*2, d = 0.9*IN;
+ const matsTB = brassMat, matsLR = brassMat;
+ const top = new THREE.Mesh(new THREE.BoxGeometry(ow, FRAME_T, d), matsTB); top.position.y = oh/2 - FRAME_T/2;
+ const bot = new THREE.Mesh(new THREE.BoxGeometry(ow, FRAME_T, d), matsTB); bot.position.y = -oh/2 + FRAME_T/2;
+ const lft = new THREE.Mesh(new THREE.BoxGeometry(FRAME_T, BOARD_H, d), matsLR); lft.position.x = -ow/2 + FRAME_T/2;
+ const rgt = new THREE.Mesh(new THREE.BoxGeometry(FRAME_T, BOARD_H, d), matsLR); rgt.position.x = ow/2 - FRAME_T/2;
+ [top,bot,lft,rgt].forEach(b=>{ b.castShadow = true; grp.add(b); });
+ return grp;
+ }
+
+ let focusIdx = -1; // currently centred/open board (slot index)
+ let centerSlot = 0; // which slot is at dead-centre
+
+ function slotX(slot){ return (slot - centerSlot) * PITCH; }
+
+ function buildBoard(d, slot){
+ const group = new THREE.Group();
+ // hinge on the LEFT edge so the board swivels open like the reference
+ const hingeX = -(BOARD_W/2 + FRAME_T);
+ group.position.set(0, BOARD_Y, BANK_Z);
+
+ const swivel = new THREE.Group(); // rotates about the hinge
+ swivel.position.x = hingeX;
+ group.add(swivel);
+
+ const inner = new THREE.Group(); // offset so geometry centres correctly
+ inner.position.x = -hingeX;
+ swivel.add(inner);
+
+ const frame = makeFrame(); inner.add(frame);
+ const face = new THREE.Mesh(faceGeo, placeholderMat.clone());
+ face.position.z = 0.5*IN; face.castShadow = false; face.receiveShadow = true;
+ inner.add(face);
+ pickables.push(face);
+
+ bankGroup.add(group);
+ const rec = { group, swivel, inner, face, data:d, slot, swivelT:0, mat:face.material };
+ face.userData.rec = rec;
+ boards.push(rec);
+
+ // stream the China Seas texture in
+ const url = d.tile || d.image;
+ if (url){
+ loader.load(url, (tex)=>{
+ tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
+ tex.anisotropy = MAXANISO;
+ if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+ else if("encoding" in tex) tex.encoding = THREE.sRGBEncoding;
+ const pm = new THREE.MeshStandardMaterial({
+ map:tex, roughness:0.82, metalness:0.0, envMapIntensity:0.45,
+ color:0xffffff, side:THREE.FrontSide
+ });
+ face.material.dispose && face.material.dispose();
+ face.material = pm; rec.mat = pm;
+ }, undefined, ()=>{ if(url!==d.image && d.image){ loader.load(d.image,(t2)=>{
+ t2.anisotropy=MAXANISO; if("colorSpace" in t2)t2.colorSpace=THREE.SRGBColorSpace;
+ face.material = new THREE.MeshStandardMaterial({map:t2,roughness:0.82,metalness:0,envMapIntensity:0.45});
+ rec.mat=face.material; }); } });
+ }
+ return rec;
+ }
+
+ // ----------------------------------------------------------------------------
+ // INTERACTION — pick a board → centre it → swivel open
+ // ----------------------------------------------------------------------------
+ const ray = new THREE.Raycaster();
+ const ndc = new THREE.Vector2();
+ let dragging=false, downX=0, downY=0, moved=0;
+
+ renderer.domElement.addEventListener("pointerdown",(e)=>{
+ dragging=true; moved=0; downX=e.clientX; downY=e.clientY;
+ });
+ renderer.domElement.addEventListener("pointermove",(e)=>{
+ if(!dragging) return; moved += Math.abs(e.clientX-downX)+Math.abs(e.clientY-downY);
+ });
+ renderer.domElement.addEventListener("pointerup",(e)=>{
+ dragging=false;
+ if(moved<6) tryPick(e);
+ });
+
+ 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 hits = ray.intersectObjects(pickables, false);
+ if(hits.length){
+ const rec = hits[0].object.userData.rec;
+ if(rec) centreOn(rec);
+ }
+ }
+
+ function centreOn(rec){
+ centerSlot = rec.slot;
+ focusIdx = rec.slot;
+ showDetail(rec);
+ updatePlate(rec);
+ }
+
+ // keyboard left/right to walk the bank (senior-friendly)
+ window.addEventListener("keydown",(e)=>{
+ if(e.key==="ArrowRight"){ const n=Math.min(boards.length-1, centerSlot+1); centreOn(boards[n]); }
+ else if(e.key==="ArrowLeft"){ const n=Math.max(0, centerSlot-1); centreOn(boards[n]); }
+ });
+
+ // ----------------------------------------------------------------------------
+ // DETAIL CARD + PLATE
+ // ----------------------------------------------------------------------------
+ const detail = document.getElementById("detail");
+ document.getElementById("d_close").addEventListener("click", ()=> detail.classList.remove("show"));
+ function showDetail(rec){
+ const d = rec.data;
+ document.getElementById("d_name").textContent = d.pattern_name || d.title || "Untitled";
+ document.getElementById("d_coll").textContent = d.collection || d.vendor || "China Seas";
+ const w = d.width || 27;
+ document.getElementById("d_dim").textContent = `${w}" × ${w}"`;
+ document.getElementById("d_rep").textContent = d.repeat ? `${d.repeat}"` : "—";
+ document.getElementById("d_match").textContent = d.match_type || "—";
+ document.getElementById("d_finish").textContent = d.finish || "—";
+ document.getElementById("d_mat").textContent = d.material || "—";
+ const cta = document.getElementById("d_cta");
+ if(d.store_url){ cta.href = d.store_url; cta.style.display="block"; } else cta.style.display="none";
+ detail.classList.add("show");
+ }
+ function updatePlate(rec){
+ const d = rec.data;
+ document.getElementById("p_name").textContent = d.pattern_name || d.title || "—";
+ document.getElementById("p_meta").textContent =
+ (d.collection || "China Seas") + (d.repeat ? " · " + d.repeat + '" repeat' : "");
+ }
+
+ // ----------------------------------------------------------------------------
+ // DATA LOAD
+ // ----------------------------------------------------------------------------
+ async function start(){
+ let products = [];
+ try {
+ const res = await fetch(API);
+ const json = await res.json();
+ products = (json.products || json || []).filter(p=> p.tile || p.image);
+ } catch(e){
+ bootlbl.textContent = "Could not reach the catalog.";
+ console.error("fetch failed", e); return;
+ }
+ products = products.slice(0, NBOARDS_TARGET);
+ if(!products.length){ bootlbl.textContent = "No designs."; return; }
+
+ products.forEach((d,i)=> buildBoard(d, i));
+ // open the bank at its middle so the first frame is full + symmetrical
+ centerSlot = Math.floor(products.length/2);
+ centreOn(boards[centerSlot]);
+
+ window.__ARTDECO_DONE = true;
+ finishBoot();
+ }
+
+ function finishBoot(){
+ const boot = document.getElementById("boot");
+ boot.style.opacity = "0";
+ setTimeout(()=> boot.remove(), 850);
+ const hint = document.getElementById("hint");
+ setTimeout(()=> hint.style.opacity = "0", 4500);
+ }
+
+ // ----------------------------------------------------------------------------
+ // RENDER LOOP — FPS readout, swivel/centre easing, pause on hidden
+ // ----------------------------------------------------------------------------
+ const fpsEl = document.getElementById("fps");
+ fpsEl.addEventListener("click", ()=> fpsEl.classList.toggle("hidden"));
+ let frames=0, fpsT0=performance.now(), fps=0;
+ let running=true, t0=performance.now();
+
+ function tick(now){
+ if(!running) return;
+ requestAnimationFrame(tick);
+ const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+ // ease the whole bank so the centred slot sits at x=0 (carousel conveyor)
+ const targetX = -centerSlot * PITCH;
+ bankGroup.position.x += (targetX - bankGroup.position.x) * Math.min(1, dt*5.5);
+
+ // each board: slide to its slot X, and swivel — the centred board opens FLAT
+ // to face the camera (full clean face); the rest rake to thin slivers.
+ for(const b of boards){
+ const isFocus = (b.slot === focusIdx);
+ // raked angle for non-focus boards (thin sliver), 0 for the open face
+ const targetSwivel = isFocus ? 0 : 1;
+ b.swivelT += (targetSwivel - b.swivelT) * Math.min(1, dt*6);
+ // open face = 0 rad (parallel to wall, facing camera); raked = ~58°
+ b.swivel.rotation.y = b.swivelT * (58 * Math.PI/180);
+ // gently pull the focused board a touch forward off the wall so nothing
+ // crosses its face
+ const fwd = isFocus ? 0.16 : 0.0;
+ b.group.position.z += ((BANK_Z + fwd) - b.group.position.z) * Math.min(1, dt*6);
+ b.group.position.x = b.slot * PITCH; // local slot, bankGroup pans the whole row
+ // dim non-focused faces slightly for focus
+ if(b.mat){
+ const wantR = isFocus ? 0.82 : 0.9;
+ if(b.mat.color){ /* keep colour */ }
+ }
+ }
+
+ // FPS
+ frames++;
+ if(now - fpsT0 >= 500){
+ fps = Math.round(frames * 1000 / (now - fpsT0));
+ fpsEl.textContent = "FPS " + fps;
+ frames = 0; fpsT0 = now;
+ window.__ARTDECO_FPS = fps;
+ }
+
+ renderer.render(scene, camera);
+ }
+ requestAnimationFrame(tick);
+
+ document.addEventListener("visibilitychange", ()=>{
+ if(document.hidden){ running=false; }
+ else if(!running){ running=true; t0=performance.now(); fpsT0=performance.now(); frames=0; requestAnimationFrame(tick); }
+ });
+
+ window.addEventListener("resize", ()=>{
+ camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ });
+
+ // headless probe
+ window.__ARTDECO__ = {
+ ready: ()=> boards.length,
+ done: ()=> !!window.__ARTDECO_DONE,
+ fps: ()=> window.__ARTDECO_FPS || 0,
+ textured: ()=> boards.filter(b=> b.mat && b.mat.map).length,
+ focus: ()=> focusIdx
+ };
+
+ start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V16","label":"V16 — Art-Deco Brass & Marble","elements":[
+ {"n":1,"label":"Brass-framed sample boards on a side swivel, ~2\" apart"},
+ {"n":2,"label":"Click a board → it shifts to centre & swivels to a clean full face"},
+ {"n":3,"label":"Polished marble floor with clearcoat reflections"},
+ {"n":4,"label":"Env-mapped reflective brass (pilasters, sunburst lintel, frames)"},
+ {"n":5,"label":"Warm key + spot lighting, symmetrical 1920s salon composition"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v21-garcia-room.html b/public/proto/v21-garcia-room.html
new file mode 100644
index 0000000..022b466
--- /dev/null
+++ b/public/proto/v21-garcia-room.html
@@ -0,0 +1,735 @@
+<!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>GARCIA ROOM · China Seas</title>
+<style>
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; overflow:hidden; background:#0a0712; color:#f3ecdc;
+ 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:20px 30px 46px;
+ pointer-events:none; text-align:center;
+ background:linear-gradient(180deg, rgba(10,7,18,.78) 0%, rgba(10,7,18,0) 100%); }
+ .masthead .kicker { font-size:11px; letter-spacing:.42em; text-transform:uppercase;
+ color:#d8b9ff; font-weight:600; text-shadow:0 0 18px rgba(180,120,255,.5); }
+ .masthead h1 { font-size:clamp(28px,5vw,52px); font-weight:300; letter-spacing:.04em;
+ line-height:.96; margin-top:6px;
+ background:linear-gradient(90deg,#ff5a7a,#ffb14e,#ffe14e,#4ee08f,#4ec3ff,#b46bff,#ff5a7a);
+ -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
+ background-size:280% 100%; animation:hueslide 14s linear infinite; }
+ @keyframes hueslide { to { background-position:280% 0; } }
+ .masthead .sub { margin-top:8px; font-size:12.5px; letter-spacing:.05em; color:#b6a8c8;
+ max-width:560px; margin-left:auto; margin-right:auto; }
+
+ .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+ font-size:11px; letter-spacing:.28em; text-transform:uppercase; color:rgba(240,230,255,.55);
+ pointer-events:none; transition:opacity .6s; text-shadow:0 0 14px rgba(160,90,255,.4); }
+
+ /* ---- FPS ---- */
+ #fps { position:fixed; bottom:14px; left:16px; z-index:25; font:600 12px/1 "SF Mono",Menlo,monospace;
+ color:#9be8c0; background:rgba(10,7,18,.55); border:1px solid rgba(155,232,192,.25);
+ padding:6px 10px; border-radius:8px; letter-spacing:.06em; pointer-events:auto; cursor:pointer;
+ backdrop-filter:blur(6px); }
+ #fps.lo { color:#ffb14e; border-color:rgba(255,177,78,.4); }
+
+ /* ---- detail card ---- */
+ #detail { position:fixed; z-index:30; right:34px; top:50%; transform:translateY(-50%) translateX(40px);
+ width:min(330px,40vw); background:rgba(18,12,28,.74); backdrop-filter:blur(18px);
+ -webkit-backdrop-filter:blur(18px); border:1px solid rgba(200,150,255,.22);
+ border-radius:6px; padding:24px 24px 26px; opacity:0; pointer-events:none;
+ box-shadow:0 24px 60px -16px rgba(120,40,200,.5);
+ transition:opacity .45s ease, transform .45s cubic-bezier(.2,.8,.2,1); }
+ #detail.show { opacity:1; transform:translateY(-50%) translateX(0); pointer-events:auto; }
+ #detail .swatchchip { width:38px; height:38px; border-radius:50%; border:1px solid rgba(255,255,255,.25);
+ margin-bottom:14px; box-shadow:0 4px 22px rgba(0,0,0,.55); }
+ #detail .pname { font-size:21px; font-weight:600; line-height:1.12; letter-spacing:.01em; }
+ #detail .pcollection { margin-top:4px; font-size:11px; letter-spacing:.26em; text-transform:uppercase; color:#c9b4e0; }
+ #detail .specrow { display:flex; justify-content:space-between; padding:9px 0;
+ border-bottom:1px solid rgba(255,255,255,.08); font-size:12.5px; }
+ #detail .specrow:first-of-type { margin-top:16px; border-top:1px solid rgba(255,255,255,.08); }
+ #detail .specrow .k { color:#a89ab8; letter-spacing:.04em; }
+ #detail .specrow .v { color:#efece5; text-align:right; }
+ #detail a.cta { display:block; margin-top:18px; text-align:center; padding:12px;
+ background:linear-gradient(90deg,#c77dff,#ff7eb0); color:#180a22; font-size:11px; letter-spacing:.22em;
+ text-transform:uppercase; font-weight:700; text-decoration:none; border-radius:3px;
+ transition:transform .15s, filter .15s; }
+ #detail a.cta:hover { transform:translateY(-1px); filter:brightness(1.08); }
+ #detail .closex { position:absolute; top:12px; right:14px; font-size:18px; color:#b6a8c8;
+ cursor:pointer; line-height:1; }
+ #detail .closex:hover { color:#fff; }
+
+ /* ---- boot ---- */
+ #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+ align-items:center; justify-content:center; background:#0a0712; transition:opacity .9s; }
+ #boot .ring { width:48px; height:48px; border-radius:50%;
+ border:3px solid rgba(255,255,255,.12);
+ border-top-color:#ff6ec7; border-right-color:#6ec7ff; border-bottom-color:#c7ff6e;
+ animation:spin 1.1s linear infinite; }
+ #boot .lbl { margin-top:20px; font-size:11px; letter-spacing:.34em; text-transform:uppercase; color:#a99cc0; }
+ @keyframes spin { to { transform:rotate(360deg); } }
+
+ @media (max-width:720px){
+ #detail { right:14px; left:14px; width:auto; top:auto; bottom:84px; transform:translateY(20px); }
+ #detail.show { transform:translateY(0); }
+ .masthead .sub { display:none; }
+ }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+ <div class="kicker">Designer Wallcoverings · Garcia Room</div>
+ <h1>GARCIA ROOM</h1>
+ <div class="sub">A liquid-light lounge bathed in a slow oil-projection color wash. Click a wing to swing
+ the China Seas board to center — one clean full pattern face, no bars.</div>
+</div>
+
+<div class="hint">click a wing · the board swings open</div>
+<div id="fps">— fps</div>
+
+<div id="detail">
+ <div class="closex">×</div>
+ <div class="swatchchip" id="d_chip"></div>
+ <div class="pname" id="d_name">—</div>
+ <div class="pcollection" id="d_coll">China Seas</div>
+ <div class="specrow"><span class="k">Dimensions</span><span class="v" id="d_dim">27" × 27"</span></div>
+ <div class="specrow"><span class="k">Repeat</span><span class="v" id="d_rep">—</span></div>
+ <div class="specrow"><span class="k">Match</span><span class="v" id="d_match">—</span></div>
+ <div class="specrow"><span class="k">Finish</span><span class="v" id="d_finish">—</span></div>
+ <div class="specrow"><span class="k">Material</span><span class="v" id="d_mat">—</span></div>
+ <a class="cta" id="d_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Tuning the light show…</div></div>
+
+<script src="./three.min.js"></script>
+<script>
+"use strict";
+(function () {
+ const API = "/api/showroom/products?limit=50";
+ const TAU = Math.PI * 2;
+ 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 app = document.getElementById("app");
+ const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+
+ const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+ renderer.setSize(W(), H());
+ renderer.outputEncoding = THREE.sRGBEncoding;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.toneMappingExposure = 1.12;
+ if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ app.appendChild(renderer.domElement);
+ const MAXANISO = renderer.capabilities.getMaxAnisotropy();
+
+ const scene = new THREE.Scene();
+ scene.fog = new THREE.FogExp2(0x140a22, 0.026);
+
+ const camera = new THREE.PerspectiveCamera(50, W()/H(), 0.1, 200);
+ // fixed viewer in the centre of the room, looking at the wing bank on the far wall
+ camera.position.set(0, 1.65, 6.7);
+ camera.lookAt(0, 1.55, -6);
+
+ // ===================================================================== //
+ // PROCEDURAL TIE-DYE PMREM ENVIRONMENT (real IBL, no external assets) //
+ // ===================================================================== //
+ function buildTieDyeEnv(){
+ // render a fullscreen tie-dye shader into a cube target, then PMREM it
+ const cubeRT = new THREE.WebGLCubeRenderTarget(256, {
+ format:THREE.RGBAFormat, generateMipmaps:true, minFilter:THREE.LinearMipmapLinearFilter
+ });
+ const cubeCam = new THREE.CubeCamera(0.1, 10, cubeRT);
+ const envScene = new THREE.Scene();
+ const skyGeo = new THREE.SphereGeometry(8, 32, 24);
+ const skyMat = new THREE.ShaderMaterial({
+ side:THREE.BackSide,
+ uniforms:{ uT:{value:0} },
+ vertexShader:`
+ varying vec3 vN;
+ void main(){ vN = normalize(position); gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }
+ `,
+ fragmentShader:`
+ precision highp float;
+ varying vec3 vN; uniform float uT;
+ vec3 hsl2rgb(float h,float s,float l){
+ vec3 r = abs(mod(h*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0;
+ r = clamp(r,0.0,1.0);
+ float c=(1.0-abs(2.0*l-1.0))*s;
+ return l + c*(r-0.5);
+ }
+ void main(){
+ // concentric tie-dye spiral driven by direction
+ float a = atan(vN.z, vN.x);
+ float r = acos(clamp(vN.y,-1.0,1.0));
+ float spiral = sin(r*5.0 + a*3.0 + uT*0.6);
+ float band = sin(r*9.0 - uT*0.4)*0.5 + 0.5;
+ float hue = fract(a/6.2831853 + r*0.4 + uT*0.05 + spiral*0.08);
+ float sat = 0.62 + 0.18*band;
+ float lit = 0.30 + 0.22*band + 0.10*spiral;
+ vec3 col = hsl2rgb(hue, sat, lit);
+ gl_FragColor = vec4(col, 1.0);
+ }
+ `
+ });
+ const sky = new THREE.Mesh(skyGeo, skyMat);
+ envScene.add(sky);
+ skyMat.uniforms.uT.value = 1.7;
+ cubeCam.update(renderer, envScene);
+
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ pmrem.compileCubemapShader();
+ const envMap = pmrem.fromCubemap(cubeRT.texture).texture;
+ pmrem.dispose(); cubeRT.dispose();
+ skyGeo.dispose(); skyMat.dispose();
+ return envMap;
+ }
+ scene.environment = buildTieDyeEnv();
+
+ // ===================================================================== //
+ // ROOM — color-wash walls (hue-cycling shader) + floor //
+ // ===================================================================== //
+ const ROOM_W = 12, ROOM_H = 5.2, ROOM_D = 13;
+ const washUniforms = { uT:{ value:0 } };
+
+ // Custom oil-projection liquid-light shader: cheap, animated via ONE uniform.
+ function washShader(seed){
+ return new THREE.ShaderMaterial({
+ uniforms:{ uT:washUniforms.uT, uSeed:{value:seed} },
+ vertexShader:`
+ varying vec2 vUv; varying vec3 vWPos;
+ void main(){ vUv=uv; vec4 wp=modelMatrix*vec4(position,1.0); vWPos=wp.xyz;
+ gl_Position=projectionMatrix*viewMatrix*wp; }
+ `,
+ fragmentShader:`
+ precision highp float;
+ varying vec2 vUv; varying vec3 vWPos; uniform float uT; uniform float uSeed;
+ vec3 hsl2rgb(float h,float s,float l){
+ vec3 r=abs(mod(h*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0; r=clamp(r,0.0,1.0);
+ float c=(1.0-abs(2.0*l-1.0))*s; return l+c*(r-0.5);
+ }
+ // smooth value noise for liquid blobs
+ float hash(vec2 p){ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
+ float vnoise(vec2 p){
+ vec2 i=floor(p), f=fract(p); f=f*f*(3.0-2.0*f);
+ float a=hash(i), b=hash(i+vec2(1.0,0.0)), c=hash(i+vec2(0.0,1.0)), d=hash(i+vec2(1.0,1.0));
+ return mix(mix(a,b,f.x),mix(c,d,f.x),f.y);
+ }
+ void main(){
+ vec2 p = vUv*3.2 + uSeed;
+ float t = uT*0.18;
+ // two drifting noise fields = lava-lamp blobs
+ float n1 = vnoise(p + vec2(t, -t*0.6));
+ float n2 = vnoise(p*1.7 - vec2(t*0.5, t));
+ float blob = smoothstep(0.35,0.85, n1*0.6 + n2*0.5);
+ // slow global hue cycle + local blob hue offset = oil-wheel rainbow
+ float hue = fract(uT*0.03 + uSeed*0.13 + blob*0.55 + n2*0.2);
+ float lit = mix(0.16, 0.46, blob); // dark ground, glowing blobs
+ float sat = mix(0.45, 0.85, blob);
+ vec3 col = hsl2rgb(hue, sat, lit);
+ // gentle vignette toward floor/ceiling so walls read as a room not a flat poster
+ float vig = smoothstep(0.0,0.35,vUv.y)*smoothstep(0.0,0.35,1.0-vUv.y);
+ col *= mix(0.55,1.0,vig);
+ gl_FragColor = vec4(col,1.0);
+ }
+ `,
+ side:THREE.FrontSide
+ });
+ }
+
+ const wallGeo = new THREE.PlaneGeometry(ROOM_W, ROOM_H, 1, 1);
+ function addWall(x,y,z,ry,seed){
+ const m = new THREE.Mesh(wallGeo, washShader(seed));
+ m.position.set(x,y,z); m.rotation.y = ry;
+ scene.add(m); return m;
+ }
+ // far wall (behind wing bank), left, right
+ addWall(0, ROOM_H/2, -ROOM_D/2, 0, 0.0);
+ addWall(-ROOM_W/2, ROOM_H/2, -ROOM_D/2 + ROOM_D/2, Math.PI/2, 2.4);
+ addWall( ROOM_W/2, ROOM_H/2, -ROOM_D/2 + ROOM_D/2, -Math.PI/2, 4.1);
+
+ // ceiling — dim color wash so emissive bloom has somewhere to breathe
+ const ceilMat = washShader(6.2); ceilMat.side = THREE.DoubleSide;
+ const ceil = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_W, ROOM_D), ceilMat);
+ ceil.rotation.x = Math.PI/2; ceil.position.set(0, ROOM_H, -ROOM_D/2 + ROOM_D/2);
+ scene.add(ceil);
+
+ // floor — dark, slightly glossy, picks up the env reflections (PBR)
+ const floorMat = new THREE.MeshPhysicalMaterial({
+ color:0x120a1c, metalness:0.0, roughness:0.32, clearcoat:0.6, clearcoatRoughness:0.4,
+ envMapIntensity:0.9, reflectivity:0.4
+ });
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_W, ROOM_D), floorMat);
+ floor.rotation.x = -Math.PI/2; floor.position.set(0, 0, -ROOM_D/2 + ROOM_D/2);
+ floor.receiveShadow = true;
+ scene.add(floor);
+
+ // ===================================================================== //
+ // LIGHTING — key + fill + warm rim, soft shadows //
+ // ===================================================================== //
+ scene.add(new THREE.HemisphereLight(0xffe6f5, 0x180a22, 0.55));
+ const key = new THREE.DirectionalLight(0xffe9d2, 1.5);
+ key.position.set(3.5, 6.5, 4.5);
+ key.castShadow = true;
+ key.shadow.mapSize.set(1024,1024);
+ key.shadow.camera.near = 1; key.shadow.camera.far = 24;
+ key.shadow.camera.left=-7; key.shadow.camera.right=7; key.shadow.camera.top=7; key.shadow.camera.bottom=-7;
+ key.shadow.bias = -0.0006;
+ scene.add(key);
+ const fill = new THREE.DirectionalLight(0x9b6bff, 0.5); fill.position.set(-5,3,5); scene.add(fill);
+ const rim = new THREE.PointLight(0xff7eb0, 1.2, 22, 2); rim.position.set(0, 3.2, -2); scene.add(rim);
+ const warm = new THREE.PointLight(0xffd28a, 0.9, 18, 2); warm.position.set(0, 2.2, 3); scene.add(warm);
+
+ // ===================================================================== //
+ // EMISSIVE DECALS — dancing bears + lightning bolts (tasteful framed) //
+ // ===================================================================== //
+ // Built procedurally onto a canvas → emissive texture in a thin gold frame.
+ function decalTexture(kind, hue){
+ const c = document.createElement("canvas"); c.width = c.height = 256;
+ const g = c.getContext("2d");
+ g.clearRect(0,0,256,256);
+ g.fillStyle = "rgba(0,0,0,0)"; g.fillRect(0,0,256,256);
+ const col = `hsl(${hue} 90% 62%)`;
+ g.fillStyle = col; g.strokeStyle = col; g.lineWidth = 8; g.lineJoin="round";
+ if(kind === "bear"){
+ // stylised dancing bear silhouette
+ g.save(); g.translate(128,150); g.scale(1.05,1.05);
+ // body
+ g.beginPath(); g.ellipse(0,10,42,52,0,0,TAU); g.fill();
+ // head
+ g.beginPath(); g.arc(0,-58,34,0,TAU); g.fill();
+ // ears
+ g.beginPath(); g.arc(-26,-82,13,0,TAU); g.fill();
+ g.beginPath(); g.arc( 26,-82,13,0,TAU); g.fill();
+ // arms (dancing — one up, one out)
+ g.lineWidth=22; g.lineCap="round";
+ g.beginPath(); g.moveTo(-30,-6); g.lineTo(-78,-44); g.stroke();
+ g.beginPath(); g.moveTo( 30,-6); g.lineTo( 80,18); g.stroke();
+ // legs (mid-step)
+ g.beginPath(); g.moveTo(-18,56); g.lineTo(-48,96); g.stroke();
+ g.beginPath(); g.moveTo( 18,56); g.lineTo( 40,100); g.stroke();
+ g.restore();
+ } else { // lightning bolt
+ g.save(); g.translate(128,30);
+ g.beginPath();
+ g.moveTo(18,0); g.lineTo(-34,120); g.lineTo(6,120);
+ g.lineTo(-22,200); g.lineTo(58,80); g.lineTo(16,80);
+ g.lineTo(54,0); g.closePath(); g.fill();
+ g.restore();
+ }
+ const tex = new THREE.CanvasTexture(c);
+ tex.encoding = THREE.sRGBEncoding;
+ return tex;
+ }
+ function addDecal(kind, hue, x, y, z, ry, size){
+ const grp = new THREE.Group();
+ // thin gold frame
+ const frame = new THREE.Mesh(
+ new THREE.PlaneGeometry(size*1.18, size*1.18),
+ new THREE.MeshPhysicalMaterial({ color:0x3a2d12, metalness:0.85, roughness:0.35,
+ envMapIntensity:1.2, emissive:0x140d04, emissiveIntensity:0.3 })
+ );
+ const art = new THREE.Mesh(
+ new THREE.PlaneGeometry(size, size),
+ new THREE.MeshBasicMaterial({ map:decalTexture(kind,hue), transparent:true,
+ toneMapped:false, depthWrite:false })
+ );
+ art.position.z = 0.02;
+ grp.add(frame, art);
+ grp.position.set(x,y,z); grp.rotation.y = ry;
+ grp.userData.glow = art; grp.userData.hue0 = hue;
+ scene.add(grp);
+ return grp;
+ }
+ const decals = [];
+ // side-wall framed art — kept small + classy, not garish
+ decals.push(addDecal("bear", 20, -ROOM_W/2+0.06, 2.4, -3.6, Math.PI/2, 0.9));
+ decals.push(addDecal("bolt", 50, -ROOM_W/2+0.06, 2.4, -1.6, Math.PI/2, 0.9));
+ decals.push(addDecal("bear", 280, ROOM_W/2-0.06, 2.4, -3.6, -Math.PI/2, 0.9));
+ decals.push(addDecal("bolt", 140, ROOM_W/2-0.06, 2.4, -1.6, -Math.PI/2, 0.9));
+
+ // ===================================================================== //
+ // WING BANK — sample boards in thin frames, ~2" apart, side-swivel //
+ // ===================================================================== //
+ const BOARD_W = 0.62, BOARD_H = 2.35, GAP = 0.052; // 30"w x ~6'tall proportions
+ const PITCH = BOARD_W*0.62 + GAP; // closed wings read as raked panels, not crushed slivers
+ const FOCUS_Z = -4.9; // open board glides forward, full face
+ const BANK_Z = -6.6; // closed bank sits back
+ const BANK_Y = 1.55;
+ const CLEAR = BOARD_W*1.9; // how far neighbors part around the open board
+
+ // shared geometries
+ const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+ const frameMat = new THREE.MeshPhysicalMaterial({
+ color:0x1a1310, metalness:0.9, roughness:0.4, envMapIntensity:1.1,
+ emissive:0x0a0604, emissiveIntensity:0.25
+ });
+ const frameBarGeo = new THREE.BoxGeometry(1,1,0.035); // scaled per use
+
+ const wings = []; // { pivot, faceMesh, data, idx, open, swing(target) }
+ const pickFaces = []; // raycast targets
+ const loader = new THREE.TextureLoader(); loader.crossOrigin = "anonymous";
+ const fallbackTex = (()=>{ // neutral until a real texture loads
+ const c=document.createElement("canvas"); c.width=c.height=4;
+ const g=c.getContext("2d"); g.fillStyle="#2a2030"; g.fillRect(0,0,4,4);
+ const t=new THREE.CanvasTexture(c); return t;
+ })();
+
+ function imgURL(u){
+ if(!u) return "";
+ if(u.charAt(0) === "/") return u; // local asset (/gen-tiles/*.png) — serve direct
+ if(/cdn\.shopify\.com/.test(u)) return u; // ACAO:* CDN — load directly
+ return "/api/proxy/image?url=" + encodeURIComponent(u);
+ }
+
+ function makeWing(d, idx, n){
+ const pivot = new THREE.Group(); // side-hinge: rotate about left edge
+ const span = (n-1)*PITCH;
+ const x = -span/2 + idx*PITCH;
+ pivot.position.set(x - BOARD_W/2, BANK_Y, BANK_Z); // pivot at the LEFT edge (the hinge)
+
+ const board = new THREE.Group();
+ board.position.x = BOARD_W/2; // shift face so hinge is at its left edge
+
+ // face — the real China Seas wallcovering
+ const faceMat = new THREE.MeshPhysicalMaterial({
+ map:fallbackTex, roughness:0.62, metalness:0.0, envMapIntensity:0.7,
+ clearcoat:0.12, clearcoatRoughness:0.6
+ });
+ const face = new THREE.Mesh(faceGeo, faceMat);
+ face.castShadow = true; face.userData.wingIdx = idx;
+ board.add(face);
+ pickFaces.push(face);
+
+ // thin frame — 4 emissive-gold bars around the face (real geometry, low cost)
+ const fb = BOARD_W*0.035; // frame thickness
+ function bar(w,h,px,py){
+ const m = new THREE.Mesh(frameBarGeo, frameMat);
+ m.scale.set(w,h,1); m.position.set(px,py,0.012);
+ board.add(m);
+ }
+ bar(BOARD_W+fb*2, fb, 0, BOARD_H/2);
+ bar(BOARD_W+fb*2, fb, 0, -BOARD_H/2);
+ bar(fb, BOARD_H, -BOARD_W/2, 0);
+ bar(fb, BOARD_H, BOARD_W/2, 0);
+
+ pivot.add(board);
+ scene.add(pivot);
+
+ // load the real texture
+ const url = imgURL(d.tile || d.image);
+ const img = new Image(); img.crossOrigin = "anonymous";
+ img.onload = ()=>{
+ const tex = new THREE.Texture(img);
+ tex.encoding = THREE.sRGBEncoding;
+ tex.anisotropy = MAXANISO;
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ // 27" pattern across a 30" board → ~1.1 repeats tall to read as wallcovering
+ tex.repeat.set(1, BOARD_H/(BOARD_W) * (27/27) * 0.5 + 0.5);
+ tex.needsUpdate = true;
+ faceMat.map = tex; faceMat.needsUpdate = true;
+ };
+ img.onerror = ()=>{};
+ img.src = url;
+
+ const wing = { pivot, board, face, faceMat, data:d, idx,
+ baseX:x, open:false, swing:0, swingTarget:0, dimT:0 };
+ wings.push(wing);
+ return wing;
+ }
+
+ // ===================================================================== //
+ // CHEAP ADDITIVE BLOOM (self-contained postprocess, FPS-safe) //
+ // ===================================================================== //
+ // Render scene → sceneRT. Bright-pass + 2 small blur RTs. Composite add.
+ let sceneRT, brightRT, blurRTa, blurRTb, fsQuad, brightMat, blurMat, compMat, orthoCam, quadGeo;
+ function makeRT(w,h){ return new THREE.WebGLRenderTarget(w,h,{ minFilter:THREE.LinearFilter,
+ magFilter:THREE.LinearFilter, format:THREE.RGBAFormat, encoding:THREE.LinearEncoding }); }
+
+ function buildBloom(){
+ const w = Math.max(2, Math.floor(W()*Math.min(window.devicePixelRatio||1,2)));
+ const h = Math.max(2, Math.floor(H()*Math.min(window.devicePixelRatio||1,2)));
+ const bw = Math.floor(w/3), bh = Math.floor(h/3); // bloom at 1/3 res = cheap
+ sceneRT = makeRT(w,h); sceneRT.texture.encoding = THREE.sRGBEncoding;
+ brightRT = makeRT(bw,bh); blurRTa = makeRT(bw,bh); blurRTb = makeRT(bw,bh);
+ orthoCam = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
+ quadGeo = new THREE.PlaneGeometry(2,2);
+
+ brightMat = new THREE.ShaderMaterial({
+ uniforms:{ tDiffuse:{value:null}, uThresh:{value:0.62} },
+ vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
+ fragmentShader:`varying vec2 vUv; uniform sampler2D tDiffuse; uniform float uThresh;
+ void main(){ vec4 c=texture2D(tDiffuse,vUv);
+ float l=dot(c.rgb,vec3(0.299,0.587,0.114));
+ float k=smoothstep(uThresh,uThresh+0.35,l);
+ gl_FragColor=vec4(c.rgb*k,1.0); }`
+ });
+ blurMat = new THREE.ShaderMaterial({
+ uniforms:{ tDiffuse:{value:null}, uDir:{value:new THREE.Vector2(1,0)}, uRes:{value:new THREE.Vector2(bw,bh)} },
+ vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
+ fragmentShader:`varying vec2 vUv; uniform sampler2D tDiffuse; uniform vec2 uDir; uniform vec2 uRes;
+ void main(){ vec2 px=uDir/uRes; vec3 s=vec3(0.0);
+ s+=texture2D(tDiffuse,vUv).rgb*0.227;
+ s+=texture2D(tDiffuse,vUv+px*1.38).rgb*0.316;
+ s+=texture2D(tDiffuse,vUv-px*1.38).rgb*0.316;
+ s+=texture2D(tDiffuse,vUv+px*3.23).rgb*0.070;
+ s+=texture2D(tDiffuse,vUv-px*3.23).rgb*0.070;
+ gl_FragColor=vec4(s,1.0); }`
+ });
+ compMat = new THREE.ShaderMaterial({
+ uniforms:{ tBase:{value:null}, tBloom:{value:null}, uStrength:{value:0.9} },
+ vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
+ fragmentShader:`varying vec2 vUv; uniform sampler2D tBase; uniform sampler2D tBloom; uniform float uStrength;
+ void main(){ vec3 b=texture2D(tBase,vUv).rgb; vec3 g=texture2D(tBloom,vUv).rgb;
+ gl_FragColor=vec4(b + g*uStrength, 1.0); }`
+ });
+ fsQuad = new THREE.Mesh(quadGeo, brightMat);
+ }
+ function resizeBloom(){
+ if(!sceneRT) return;
+ const dpr = Math.min(window.devicePixelRatio||1,2);
+ const w=Math.floor(W()*dpr), h=Math.floor(H()*dpr), bw=Math.floor(w/3), bh=Math.floor(h/3);
+ sceneRT.setSize(w,h); brightRT.setSize(bw,bh); blurRTa.setSize(bw,bh); blurRTb.setSize(bw,bh);
+ blurMat.uniforms.uRes.value.set(bw,bh);
+ }
+ function renderWithBloom(){
+ // 1) scene → sceneRT
+ renderer.setRenderTarget(sceneRT);
+ renderer.clear();
+ renderer.render(scene, camera);
+ // 2) bright pass
+ fsQuad.material = brightMat; brightMat.uniforms.tDiffuse.value = sceneRT.texture;
+ renderer.setRenderTarget(brightRT); renderer.render(fsQuad, orthoCam);
+ // 3) blur H then V (x2 light)
+ fsQuad.material = blurMat;
+ blurMat.uniforms.tDiffuse.value = brightRT.texture; blurMat.uniforms.uDir.value.set(1,0);
+ renderer.setRenderTarget(blurRTa); renderer.render(fsQuad, orthoCam);
+ blurMat.uniforms.tDiffuse.value = blurRTa.texture; blurMat.uniforms.uDir.value.set(0,1);
+ renderer.setRenderTarget(blurRTb); renderer.render(fsQuad, orthoCam);
+ blurMat.uniforms.tDiffuse.value = blurRTb.texture; blurMat.uniforms.uDir.value.set(1.7,0);
+ renderer.setRenderTarget(blurRTa); renderer.render(fsQuad, orthoCam);
+ blurMat.uniforms.tDiffuse.value = blurRTa.texture; blurMat.uniforms.uDir.value.set(0,1.7);
+ renderer.setRenderTarget(blurRTb); renderer.render(fsQuad, orthoCam);
+ // 4) composite to screen
+ fsQuad.material = compMat;
+ compMat.uniforms.tBase.value = sceneRT.texture; compMat.uniforms.tBloom.value = blurRTb.texture;
+ renderer.setRenderTarget(null); renderer.render(fsQuad, orthoCam);
+ }
+
+ // ===================================================================== //
+ // PICKING / FOCUS //
+ // ===================================================================== //
+ const ray = new THREE.Raycaster();
+ const ndc = new THREE.Vector2();
+ let focused = null;
+
+ function openWing(w){
+ if(focused === w){ closeWing(); return; }
+ wings.forEach(x=>{ x.open = false; x.swingTarget = 0; });
+ w.open = true; w.swingTarget = 1;
+ focused = w;
+ showDetail(w);
+ }
+ function closeWing(){
+ if(focused){ focused.open=false; focused.swingTarget=0; }
+ focused = null; hideDetail();
+ }
+
+ 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 hits = ray.intersectObjects(pickFaces, false);
+ if(hits.length){
+ const idx = hits[0].object.userData.wingIdx;
+ const w = wings.find(x=>x.idx===idx);
+ if(w) openWing(w);
+ } else if(focused){ closeWing(); }
+ }
+
+ // drag to slide the bank laterally + click to open
+ let dragging=false, dragX=0, panStart=0, moved=0;
+ let bankPan = 0, bankPanTarget = 0;
+ const el = renderer.domElement;
+ el.addEventListener("pointerdown",(e)=>{ dragging=true; moved=0; dragX=e.clientX; panStart=bankPanTarget;
+ el.setPointerCapture(e.pointerId); });
+ el.addEventListener("pointermove",(e)=>{ if(!dragging) return;
+ const dx=e.clientX-dragX; moved+=Math.abs(dx);
+ bankPanTarget = clamp(panStart + dx*0.012, -2.6, 2.6); });
+ el.addEventListener("pointerup",(e)=>{ dragging=false; if(moved<6) tryPick(e); });
+ window.addEventListener("wheel",(e)=>{ bankPanTarget = clamp(bankPanTarget - e.deltaY*0.0016, -2.6, 2.6); }, {passive:true});
+
+ // ---- detail card ----
+ const detail = document.getElementById("detail");
+ document.querySelector("#detail .closex").addEventListener("click", closeWing);
+ function showDetail(w){
+ const d = w.data;
+ document.getElementById("d_name").textContent = d.pattern_name || d.title || "Untitled";
+ document.getElementById("d_coll").textContent = (d.collection || d.vendor || "China Seas");
+ const ww = d.width||27;
+ document.getElementById("d_dim").textContent = `${ww}" × ${ww}"`;
+ document.getElementById("d_rep").textContent = d.repeat ? `${d.repeat}"` : "—";
+ document.getElementById("d_match").textContent = d.match_type || "—";
+ document.getElementById("d_finish").textContent = d.finish || "—";
+ document.getElementById("d_mat").textContent = d.material || "—";
+ const chip = document.getElementById("d_chip");
+ chip.style.background = `conic-gradient(from 0deg,#ff5a7a,#ffb14e,#ffe14e,#4ee08f,#4ec3ff,#b46bff,#ff5a7a)`;
+ const cta = document.getElementById("d_cta");
+ if(d.store_url){ cta.href = d.store_url; cta.style.display="block"; }
+ else cta.style.display="none";
+ detail.classList.add("show");
+ }
+ function hideDetail(){ detail.classList.remove("show"); }
+
+ // ===================================================================== //
+ // RESIZE / VISIBILITY //
+ // ===================================================================== //
+ let running = true;
+ window.addEventListener("resize", ()=>{
+ camera.aspect = W()/H(); camera.updateProjectionMatrix();
+ renderer.setSize(W(), H()); resizeBloom();
+ });
+ document.addEventListener("visibilitychange", ()=>{
+ running = !document.hidden;
+ if(running){ t0 = performance.now(); requestAnimationFrame(tick); }
+ });
+
+ // ===================================================================== //
+ // FPS //
+ // ===================================================================== //
+ const fpsEl = document.getElementById("fps");
+ let fpsShown = true;
+ fpsEl.addEventListener("click", ()=>{ fpsShown=!fpsShown; fpsEl.style.opacity = fpsShown?"1":"0.25"; });
+ let frames=0, fpsAccum=0, fps=0;
+
+ // ===================================================================== //
+ // DATA LOAD //
+ // ===================================================================== //
+ const bootlbl = document.getElementById("bootlbl");
+ async function start(){
+ let products = [];
+ try {
+ const res = await fetch(API);
+ const json = await res.json();
+ products = json.products || [];
+ } catch(e){ bootlbl.textContent = "Could not reach the catalog."; console.error("fetch failed", e); }
+ const n = Math.min(products.length, 28); // a focused wing bank, not a wall of 50 slivers
+ for(let i=0;i<n;i++) makeWing(products[i], i, n);
+ window.__GARCIA_READY = wings.length;
+ buildBloom();
+ finishBoot();
+ t0 = performance.now();
+ requestAnimationFrame(tick);
+ }
+ function finishBoot(){
+ const boot = document.getElementById("boot");
+ boot.style.opacity = "0";
+ setTimeout(()=>boot.remove(), 950);
+ }
+
+ // ===================================================================== //
+ // ANIMATE //
+ // ===================================================================== //
+ let t0 = performance.now();
+ function tick(now){
+ if(!running) return;
+ const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+ // FPS
+ frames++; fpsAccum += dt;
+ if(fpsAccum >= 0.5){ fps = Math.round(frames/fpsAccum); frames=0; fpsAccum=0;
+ if(fpsShown){ fpsEl.textContent = fps + " fps"; fpsEl.classList.toggle("lo", fps<55); } }
+
+ // color-wash uniform — ONE float, drives every wall/ceiling shader (cheap)
+ washUniforms.uT.value = now*0.001;
+
+ // breathing rim/warm light for liquid-light pulse
+ const pulse = (Math.sin(now*0.0011)*0.5+0.5);
+ rim.intensity = 0.9 + pulse*0.9;
+ rim.color.setHSL((now*0.00006)%1, 0.7, 0.6);
+ warm.intensity = 0.7 + (1-pulse)*0.5;
+
+ // decals: slow emissive glow shimmer + gentle hue drift (classy, not flashing)
+ for(let i=0;i<decals.length;i++){
+ const g = decals[i].userData.glow;
+ const op = 0.78 + Math.sin(now*0.0015 + i*1.7)*0.18;
+ g.material.opacity = op;
+ }
+
+ // bank lateral pan
+ bankPan += (bankPanTarget - bankPan)*Math.min(1,dt*5);
+
+ // wings: swivel open/closed; the focused board swings flat to camera + slides to center,
+ // and PARTS its neighbors aside so NOTHING crosses the open full-pattern face (no gray bars).
+ const fIdx = focused ? focused.idx : null;
+ for(const w of wings){
+ w.swing += (w.swingTarget - w.swing)*Math.min(1,dt*7);
+ // closed wings rake ~58° (read as a packed bank); open wing → 0° (flat, full face to camera)
+ const rake = lerp(-1.02, 0.0, w.swing);
+ w.pivot.rotation.y = rake;
+
+ // base bank x, plus a parting shove when a different wing is focused
+ let wantX = w.baseX;
+ if(fIdx !== null && w !== focused){
+ const side = (w.idx < fIdx) ? -1 : 1; // push left of focus left, right of focus right
+ const part = CLEAR * (focused.swing); // grows as the focus board opens
+ wantX = w.baseX + side*part;
+ }
+ // focused board glides forward to FOCUS_Z and to x=0 (dead center)
+ wantX = lerp(wantX, 0, w.swing);
+ const wantZ = lerp(BANK_Z, FOCUS_Z, w.swing);
+ const px = wantX - BOARD_W/2 + bankPan*(1-w.swing);
+ w.pivot.position.x += (px - w.pivot.position.x)*Math.min(1,dt*6);
+ w.pivot.position.z += (wantZ - w.pivot.position.z)*Math.min(1,dt*6);
+ w.pivot.position.y = BANK_Y + Math.sin(now*0.0006 + w.idx)*0.012*(1-w.swing);
+
+ // open board scales up to a generous full face; closed boards stay bank-size
+ const sc = lerp(1.0, 1.32, w.swing);
+ w.board.scale.set(sc, sc, 1);
+
+ // dim non-focused faces when one is open so the open face reads clean
+ const wantDim = (focused && w!==focused) ? 1 : 0;
+ w.dimT += (wantDim - w.dimT)*Math.min(1,dt*6);
+ w.faceMat.color.setScalar(lerp(1, 0.5, w.dimT));
+ w.faceMat.envMapIntensity = lerp(0.7, 1.05, w.swing); // open face catches more env sheen
+ }
+
+ renderWithBloom();
+ requestAnimationFrame(tick);
+ }
+
+ // probe for headless verification
+ window.__GARCIA__ = {
+ ready:()=>wings.length,
+ fps:()=>fps,
+ focused:()=>focused? focused.idx : -1,
+ texturesLoaded:()=>wings.filter(w=>w.faceMat.map && w.faceMat.map!==fallbackTex).length
+ };
+
+ start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V21","label":"V21 — Garcia Room","elements":[
+ {"n":1,"label":"Liquid-light color-wash walls (slow oil-projection hue cycle)"},
+ {"n":2,"label":"Tie-dye PMREM environment lighting the room"},
+ {"n":3,"label":"Wing bank of China Seas boards in thin gold frames, ~2\" apart"},
+ {"n":4,"label":"Click a wing → it swivels to a clean full-pattern face, no bars"},
+ {"n":5,"label":"Dancing-bear + lightning-bolt framed art with emissive bloom"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
← 1c4a04e Avatar polish: lighten 1st-person sweater 0x2e3138->0x3d424d
·
back to Quadrille Showroom
·
Register V12 Gallery Noir + V16 Art-Deco Salon + V21 Garcia fad82ea →