← back to Quadrille Showroom
public/proto/v21-garcia-room.html
736 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>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>