← back to Quadrille Showroom
public/proto/v18-infinity-mirror.html
695 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>INFINITY MIRROR · China Seas</title>
<style>
:root { --neon:#bfe9ff; --amber:#ffca7a; --paper:#eef2f6; }
* { box-sizing:border-box; margin:0; padding:0; }
html,body { height:100%; overflow:hidden; background:#04060a; 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:64px 30px 46px;
pointer-events:none; text-align:center;
background:linear-gradient(180deg, rgba(4,6,10,.78) 0%, rgba(4,6,10,0) 100%); }
.masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
color:#7fb9d6; font-weight:600; }
.masthead h1 { font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:clamp(26px,4.6vw,48px);
font-weight:200; letter-spacing:.26em; line-height:1; margin-top:11px; color:#f1f6fb;
text-shadow:0 0 22px rgba(140,210,255,.35); }
.masthead h1 b { font-weight:600; color:#bfe9ff; text-shadow:0 0 26px rgba(140,210,255,.6); }
.masthead .sub { margin-top:11px; font-size:12px; letter-spacing:.05em; color:#7e8a96;
max-width:560px; margin-left:auto; margin-right:auto; }
/* ---- 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 .5s, transform .5s cubic-bezier(.2,.8,.2,1);
pointer-events:none; }
#caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
#caption .cn { font-size:22px; font-weight:300; letter-spacing:.04em; color:#f1f6fb;
text-shadow:0 2px 22px rgba(0,0,0,.9), 0 0 18px rgba(150,215,255,.3); }
#caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#86c6e6; }
#caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#8c97a3; }
#caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
border:1px solid rgba(150,215,255,.5); border-radius:28px; color:#bfe9ff; font-size:10.5px;
letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
background:rgba(150,215,255,.05); transition:background .15s, transform .15s, box-shadow .15s;
box-shadow:0 0 14px rgba(150,215,255,.12); }
#caption a.cta:hover { background:rgba(150,215,255,.14); transform:translateY(-1px);
box-shadow:0 0 22px rgba(150,215,255,.3); }
.hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(190,233,255,.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:#7fb9d6; background:rgba(6,9,14,.62); border:1px solid rgba(150,215,255,.22); 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:#04060a; transition:opacity .9s; }
#boot .ring { width:44px; height:44px; border-radius:50%;
border:2px solid rgba(150,215,255,.16); border-top-color:#bfe9ff; animation:spin 1s linear infinite; }
#boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#5f7282; }
@keyframes spin { to { transform:rotate(360deg); } }
@media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:58px;} }
</style>
</head>
<body>
<div id="app"></div>
<div class="masthead">
<div class="kicker">Designer Wallcoverings · The Infinity Gallery</div>
<h1>INFINITY <b>MIRROR</b></h1>
<div class="sub">China Seas, hung in a mirrored hall. Floor and side walls reflect the bank into the
distance, traced by a thread of cool neon. 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">Lighting the mirror hall…</div></div>
<!-- r128 core (local). RoomEnvironment from CDN for IBL; NO bloom dep (neon faked via emissive + additive planes). -->
<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));
// -------------------------------------------------------------------------
// 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.06;
if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0x04060a, 1);
app.appendChild(renderer.domElement);
const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05070c);
scene.fog = new THREE.FogExp2(0x05070c, 0.034);
const camera = new THREE.PerspectiveCamera(48, W()/H(), 0.1, 220);
camera.position.set(0, 1.55, 9.2);
camera.lookAt(0, 1.55, 0);
// -------------------------------------------------------------------------
// PMREM ENVIRONMENT (RoomEnvironment) — drives reflections on the mirror
// surfaces and gloss on the boards. We blend in a couple of emissive neon
// panels so the env map carries a faint cool/amber tint into the mirrors.
// -------------------------------------------------------------------------
let ENV_MAP = null;
(function buildEnv(){
try {
const pmrem = new THREE.PMREMGenerator(renderer);
const env = new THREE.RoomEnvironment();
ENV_MAP = pmrem.fromScene(env, 0.04).texture;
scene.environment = ENV_MAP;
pmrem.dispose();
} catch(e){ /* env optional */ }
})();
// -------------------------------------------------------------------------
// ONE infrequently-updated CubeCamera reflection probe at the hall centre.
// The mirror floor + side walls sample THIS cube map (a single shared env)
// rather than recursive real-time reflectors — cheap, holds FPS. We refresh
// it only every Nth frame (and only when the scene meaningfully changed).
// -------------------------------------------------------------------------
const cubeRT = new THREE.WebGLCubeRenderTarget(256, {
format: THREE.RGBAFormat,
generateMipmaps: true,
minFilter: THREE.LinearMipmapLinearFilter
});
if ("texture" in cubeRT && "colorSpace" in cubeRT.texture) cubeRT.texture.colorSpace = THREE.SRGBColorSpace;
else if (cubeRT.texture) cubeRT.texture.encoding = THREE.sRGBEncoding;
const cubeCam = new THREE.CubeCamera(0.1, 80, cubeRT);
cubeCam.position.set(0, 1.4, 2.0);
scene.add(cubeCam);
let cubeReady = false;
// -------------------------------------------------------------------------
// LIGHTING — cool key + warm rim + low fill; high-end gallery-futuristic.
// -------------------------------------------------------------------------
scene.add(new THREE.AmbientLight(0x10161f, 0.45));
const fill = new THREE.DirectionalLight(0x5d7d9c, 0.26);
fill.position.set(-7, 6, 6); scene.add(fill);
const rim = new THREE.DirectionalLight(0xffd4a0, 0.30); // warm amber rim from behind
rim.position.set(6, 4, -8); scene.add(rim);
const key = new THREE.DirectionalLight(0xeaf4ff, 0.42); // cool 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);
// featured-board spotlight (cool white, tight)
const spot = new THREE.SpotLight(0xdcefff, 0, 18, Math.PI/7.5, 0.5, 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 = 24;
spot.shadow.bias = -0.0005; spot.shadow.radius = 5;
scene.add(spot);
const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
spot.target = spotTarget;
// -------------------------------------------------------------------------
// ROOM — MIRRORED floor + side walls. Instead of THREE.Reflector (real-time,
// expensive), the mirror surfaces are env-mapped MeshStandardMaterial at high
// metalness / very low roughness sampling the shared CubeCamera probe. This
// gives a believable infinity-repeat of the bank without recursive reflection.
// -------------------------------------------------------------------------
const FLOOR_Y = 0;
let mirrorFloorMat=null, mirrorLeftMat=null, mirrorRightMat=null;
const neonGlows = []; // {mat, base} for a gentle breathing pulse — declared before buildRoom()→buildNeon()
(function buildRoom(){
// back wall — dark, faint sheen so neon halo registers
const back = new THREE.Mesh(
new THREE.PlaneGeometry(80, 30),
new THREE.MeshStandardMaterial({ color:0x070a10, roughness:0.92, metalness:0.05, envMapIntensity:0.3 })
);
back.position.set(0, 8, -8.5); back.receiveShadow = true; scene.add(back);
// ceiling — near-black, holds the ceiling neon line
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(80, 60),
new THREE.MeshStandardMaterial({ color:0x05070b, roughness:1.0 }));
ceil.position.set(0, 13.2, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
// MIRROR FLOOR — high metalness, low roughness, samples the cube probe.
// A faint procedural roughness keeps it from being a perfect (banded) mirror.
mirrorFloorMat = new THREE.MeshStandardMaterial({
color:0x0a0e14, metalness:1.0, roughness:0.14, envMapIntensity:1.25
});
mirrorFloorMat.roughnessMap = makeMicroRoughness(118, 30);
mirrorFloorMat.roughnessMap.wrapS = mirrorFloorMat.roughnessMap.wrapT = THREE.RepeatWrapping;
mirrorFloorMat.roughnessMap.repeat.set(10, 10);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 60), mirrorFloorMat);
floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
scene.add(floor);
// MIRROR SIDE WALLS — same treatment, so the bank repeats sideways into the
// distance on both sides (the "infinity" of the aesthetic).
mirrorLeftMat = new THREE.MeshStandardMaterial({
color:0x090d13, metalness:1.0, roughness:0.18, envMapIntensity:1.1
});
mirrorLeftMat.roughnessMap = mirrorFloorMat.roughnessMap;
mirrorRightMat = mirrorLeftMat.clone();
mirrorRightMat.roughnessMap = mirrorFloorMat.roughnessMap;
const left = new THREE.Mesh(new THREE.PlaneGeometry(60, 30), mirrorLeftMat);
left.position.set(-11.5, 8, 0); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
const right = new THREE.Mesh(new THREE.PlaneGeometry(60, 30), mirrorRightMat);
right.position.set(11.5, 8, 0); right.rotation.y = -Math.PI/2; right.receiveShadow = true; scene.add(right);
buildNeon();
})();
function makeMicroRoughness(base, spread){
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()*spread)|0; d[i]=d[i+1]=d[i+2]=base+n; }
x.putImageData(img,0,0);
const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
}
// -------------------------------------------------------------------------
// NEON EDGE-LIGHT — thin emissive LED lines tracing the ceiling + the wall/
// floor seams, with FAKE glow (additive blur planes around each line). Cool
// white for the verticals, a warm amber line along the floor seam. No bloom
// pass needed; this reads as crisp neon and is nearly free.
// -------------------------------------------------------------------------
function neonLine(len, thick, color, glowColor, glowScale){
const g = new THREE.Group();
// the crisp core line — emissive, unlit, additive so it pops
const core = new THREE.Mesh(
new THREE.PlaneGeometry(len, thick),
new THREE.MeshBasicMaterial({ color:color, toneMapped:false })
);
g.add(core);
// the fake-glow halo — a wider, soft additive plane behind it
const haloMat = new THREE.MeshBasicMaterial({
color:glowColor, transparent:true, opacity:0.5, blending:THREE.AdditiveBlending,
depthWrite:false, toneMapped:false
});
const halo = new THREE.Mesh(new THREE.PlaneGeometry(len*1.01, thick*glowScale), haloMat);
halo.position.z = -0.01;
g.add(halo);
neonGlows.push({ mat:haloMat, base:0.5 });
return g;
}
function buildNeon(){
const COOL = 0x9fe2ff, COOLG = 0x4ea8e0;
const AMBER = 0xffc278, AMBERG = 0xe08a3a;
// ceiling centre line running into the distance (the spine of the hall)
const ceilLine = neonLine(70, 0.06, COOL, COOLG, 9);
ceilLine.rotation.x = Math.PI/2; // lie flat on the ceiling
ceilLine.position.set(0, 13.15, 0);
scene.add(ceilLine);
// two ceiling-edge runs where ceiling meets the side walls (perspective rails)
[-11.35, 11.35].forEach(xp=>{
const r = neonLine(70, 0.05, COOL, COOLG, 8);
r.rotation.x = Math.PI/2;
r.position.set(xp, 13.1, 0);
scene.add(r);
});
// FLOOR/WALL seam amber lines (both sides) — warm counterpoint, low to the
// mirror floor so they smear into a reflected glow.
[-11.4, 11.4].forEach((xp,i)=>{
const r = neonLine(70, 0.06, AMBER, AMBERG, 10);
r.rotation.x = -Math.PI/2;
r.position.set(xp, 0.06, 0);
scene.add(r);
});
// vertical neon at the back corners — frames the far end of the hall
[-11.4, 11.4].forEach(xp=>{
const v = neonLine(26, 0.05, COOL, COOLG, 9);
v.rotation.z = Math.PI/2;
v.position.set(xp, 8, -8.3);
scene.add(v);
});
}
// -------------------------------------------------------------------------
// THE WING BANK — thin-framed boards, ~2" apart, side swivel. (Mechanic
// reused from v6/v12: pivot on the left edge, carousel-shift to centre, the
// focused board swings fully face-on to a clean full pattern face.)
// -------------------------------------------------------------------------
const BOARD_W = 1.46; // ~30" wide board face
const BOARD_H = 3.55; // ~6' tall
const FRAME_T = 0.045;
const FRAME_D = 0.05;
const GAP = 0.10; // ~2" between closed boards
const PITCH = BOARD_W + GAP;
const BANK_Z = -4.4;
const N_TARGET = 50;
const boards = [];
let products = [];
let focusIdx = 0;
const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
// cool-tinted brushed-metal frame (catches the neon + env)
const frameMat = new THREE.MeshStandardMaterial({ color:0x1a2029, roughness:0.34, metalness:0.92, envMapIntensity:1.1 });
function placeholderFace(){
return new THREE.MeshPhysicalMaterial({ color:0x141820, 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:0x0c0f15, roughness:0.8, metalness:0.1 })
);
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);
// a thin emissive neon edge on the FOCUSED board's frame (faded in/out) so
// the featured piece glows tastefully — a single additive outline plane.
const edge = new THREE.Mesh(
new THREE.PlaneGeometry(BOARD_W + 0.14, BOARD_H + 0.14),
new THREE.MeshBasicMaterial({ color:0x9fe2ff, transparent:true, opacity:0, blending:THREE.AdditiveBlending, depthWrite:false, toneMapped:false })
);
edge.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.04);
g.add(edge);
scene.add(g);
return { group:g, face, faceMat, edge, idx, data:null, texLoaded:false, swivel:0, _lit:undefined };
}
// -------------------------------------------------------------------------
// TEXTURE LOADER — lazy: only 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.5, metalness:0.0, clearcoat:0.14, clearcoatRoughness:0.55, envMapIntensity:0.5
});
b.face.material.dispose && b.face.material.dispose();
b.face.material = m; b.faceMat = m;
cubeReady = false; // force a reflection-probe refresh after a new texture
});
}
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.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]);
cubeReady = false; // re-render the probe when the centred board changes
}
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);
cubeReady = false;
});
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); }
});
// -------------------------------------------------------------------------
// REFLECTION PROBE refresh — cheap: hide the mirror surfaces, render the cube
// from the hall centre, assign to the mirror materials. Done only when the
// scene changed (cubeReady=false) and at most ~once/sec otherwise.
// -------------------------------------------------------------------------
let probeFrame = 0;
function updateProbe(){
if(!mirrorFloorMat) return;
// hide mirrors so they don't reflect themselves (no recursion)
const hidden = [];
scene.traverse(o=>{
if(o.isMesh && (o.material===mirrorFloorMat || o.material===mirrorLeftMat || o.material===mirrorRightMat)){
if(o.visible){ hidden.push(o); o.visible=false; }
}
});
cubeCam.update(renderer, scene);
hidden.forEach(o=>o.visible=true);
mirrorFloorMat.envMap = cubeRT.texture;
mirrorLeftMat.envMap = cubeRT.texture;
mirrorRightMat.envMap = cubeRT.texture;
mirrorFloorMat.needsUpdate = mirrorLeftMat.needsUpdate = mirrorRightMat.needsUpdate = true;
cubeReady = true;
}
// -------------------------------------------------------------------------
// 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;
// brightness recede — only the featured board is fully lit
const litTarget = 0.30 + 0.70*Math.pow(openAmt, 1.3);
if(b._lit===undefined) b._lit = litTarget;
b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
if(b.faceMat){ b.faceMat.color.setScalar(b._lit); }
// neon frame glow only on the centred board
const edgeTarget = 0.0 + 0.55*Math.pow(openAmt, 2.2);
b.edge.material.opacity += (edgeTarget - b.edge.material.opacity)*Math.min(1,dt*6);
const farOff = Math.abs(rel) > 8;
b.group.visible = !farOff;
}
// spotlight tracks 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?9.0:0) - spot.intensity)*Math.min(1,dt*5);
}
// neon breathing pulse (subtle, tasteful)
const pulse = 0.5 + Math.sin(now*0.0014)*0.12;
for(const ng of neonGlows){ ng.mat.opacity = pulse; }
// refresh the reflection probe lazily: when flagged dirty, and otherwise a
// gentle periodic refresh so the breathing neon shows in the mirrors too.
probeFrame++;
if(!cubeReady || (probeFrame % 90 === 0)){
updateProbe();
}
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.__IM_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]);
// prime the reflection probe once before reveal
updateProbe();
window.__IM__ = {
boards: ()=>boards.length,
textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
focus: ()=>focusIdx,
mirrors: ()=>!!(mirrorFloorMat && mirrorFloorMat.envMap),
fps: ()=>window.__IM_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":"V18","label":"V18 — Infinity Mirror","elements":[
{"n":1,"label":"Mirrored floor + side walls — the bank repeats into the distance"},
{"n":2,"label":"Crisp cool-white + amber neon edge-light tracing ceiling & seams"},
{"n":3,"label":"Thin brushed-metal framed boards, ~2\" apart on a side swivel"},
{"n":4,"label":"Featured board glows with a neon edge under a cool spotlight"},
{"n":5,"label":"Click a board → carousel-shift to centre → clean full pattern face"}
]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>