← back to Quadrille Showroom
public/proto/v9-walkin.html
491 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<title>WALK-IN ROOM — China Seas</title>
<style>
:root { --ink:#2b2622; --paper:#f4efe7; }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; overflow:hidden; background:#1a1714;
font-family:"Hoefler Text", Georgia, "Times New Roman", serif; color:var(--paper); }
#app { position:fixed; inset:0; }
canvas { display:block; }
/* top brand bar */
.topbar { position:fixed; top:0; left:0; right:0; z-index:10;
display:flex; align-items:center; justify-content:space-between;
padding:14px 22px; pointer-events:none;
background:linear-gradient(to bottom, rgba(20,17,14,.55), rgba(20,17,14,0)); }
.brand { letter-spacing:.32em; font-size:12px; text-transform:uppercase; opacity:.9; }
.now { text-align:right; font-size:11px; letter-spacing:.06em; line-height:1.5; opacity:.85; max-width:46%; }
.now b { display:block; letter-spacing:.04em; font-size:13px; }
/* bottom design switcher strip */
.strip { position:fixed; left:0; right:0; bottom:0; z-index:10;
padding:14px 16px 18px; display:flex; gap:12px; align-items:flex-end;
overflow-x:auto; overflow-y:hidden;
background:linear-gradient(to top, rgba(20,17,14,.82), rgba(20,17,14,0)); }
.strip::-webkit-scrollbar { height:6px; }
.strip::-webkit-scrollbar-thumb { background:rgba(244,239,231,.25); border-radius:3px; }
.swatch { flex:0 0 auto; width:78px; cursor:pointer; text-align:center;
opacity:.72; transition:opacity .2s, transform .2s; }
.swatch:hover { opacity:1; transform:translateY(-3px); }
.swatch.active { opacity:1; }
.swatch img { width:78px; height:78px; object-fit:cover; border-radius:6px;
border:2px solid transparent; background:#3a342d; display:block; }
.swatch.active img { border-color:var(--paper); box-shadow:0 6px 18px rgba(0,0,0,.5); }
.swatch span { display:block; margin-top:5px; font-size:9.5px; line-height:1.25;
letter-spacing:.02em; opacity:.85; max-height:24px; overflow:hidden;
text-transform:uppercase; }
.hint { position:fixed; left:50%; top:54%; transform:transl(-50%,-50%);
transform:translateX(-50%); z-index:9; pointer-events:none;
font-size:11px; letter-spacing:.18em; text-transform:uppercase;
opacity:.0; transition:opacity 1s; text-shadow:0 1px 6px rgba(0,0,0,.6); }
.hint.show { opacity:.55; }
.loading { position:fixed; inset:0; z-index:20; display:flex;
align-items:center; justify-content:center; flex-direction:column; gap:14px;
background:#1a1714; transition:opacity .6s; }
.loading.gone { opacity:0; pointer-events:none; }
.ring { width:34px; height:34px; border:2px solid rgba(244,239,231,.2);
border-top-color:var(--paper); border-radius:50%; animation:spin 1s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.loading p { letter-spacing:.28em; text-transform:uppercase; font-size:11px; opacity:.7; }
</style>
</head>
<body>
<div id="app"></div>
<div class="topbar">
<div class="brand">China Seas · Walk-In Room</div>
<div class="now" id="now"><b>—</b><span id="meta"></span></div>
</div>
<div class="hint" id="hint">Drag to look around the room</div>
<div class="strip" id="strip"></div>
<div class="loading" id="loading"><div class="ring"></div><p>Papering the room…</p></div>
<script src="./three.min.js"></script>
<script src="./OrbitControls.js"></script>
<script>
(function(){
"use strict";
const FT = 0.3048; // 1 ft in metres
const REPEAT_IN = 27; // architectural wallpaper repeat (inches)
const REPEAT_M = REPEAT_IN * 0.0254; // ~0.6858 m
// Room dimensions (a real-feeling living room): 16ft W x 13ft D x 9ft H
const W = 16 * FT, D = 13 * FT, H = 9 * FT;
const EYE = 1.62; // standing eye height
const app = document.getElementById('app');
// ---- renderer ----
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
if ('outputColorSpace' in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
else if ('outputEncoding' in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x2a2520);
// ---- camera INSIDE the room, near the back wall, looking across ----
const camera = new THREE.PerspectiveCamera(72, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(0, EYE, D*0.30); // standing a bit back from centre
// OrbitControls used as a "look around from inside" rig: target sits just
// in front of the eye, so dragging rotates the view, not an exterior orbit.
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(-W*0.32, EYE, -D*0.5); // look toward far wall + window corner
controls.enablePan = false;
controls.enableZoom = false; // it's a room, not a model — no zoom
controls.rotateSpeed = -0.32; // negative = natural "look" direction
controls.minPolarAngle = THREE.MathUtils.degToRad(58);
controls.maxPolarAngle = THREE.MathUtils.degToRad(118);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// keep the pivot pinned to the eye every frame (first-person look)
function lockPivot(){
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
controls.target.copy(camera.position).add(dir.multiplyScalar(0.6));
}
// ================= LIGHTING (warm, soft) =================
const hemi = new THREE.HemisphereLight(0xfff3e0, 0x3a2f26, 0.42);
scene.add(hemi);
const ambient = new THREE.AmbientLight(0xffe8cf, 0.26);
scene.add(ambient);
// Daylight pouring through the window (key light), warm + directional so the
// walls carry a light->shadow gradient (depth cue: you're in a real lit room)
const sun = new THREE.DirectionalLight(0xfff1da, 1.55);
sun.position.set(-W*0.55, H*0.9, D*0.55);
sun.castShadow = true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.near = 0.5; sun.shadow.camera.far = 30;
sun.shadow.camera.left = -W; sun.shadow.camera.right = W;
sun.shadow.camera.top = H; sun.shadow.camera.bottom = -H;
sun.shadow.bias = -0.0004;
scene.add(sun);
// soft warm fill bounce from the room interior
const fill = new THREE.PointLight(0xffd9a8, 0.45, 18, 2);
fill.position.set(W*0.25, H*0.7, D*0.1);
scene.add(fill);
// gentle lamp glow near the sofa
const lamp = new THREE.PointLight(0xffcaa0, 0.55, 9, 2);
lamp.position.set(W*0.32, 1.4, -D*0.30);
scene.add(lamp);
// bright daylight spill right at the window so that wall glows (falloff = depth)
const dayspill = new THREE.PointLight(0xfff4e0, 0.85, 7, 2);
dayspill.position.set(-W*0.5 + 0.4, 1.4, -D*0.10);
scene.add(dayspill);
// ================= ROOM SHELL =================
const tex = { loader: new THREE.TextureLoader() };
tex.loader.crossOrigin = 'anonymous';
// CONTRARIAN FIX — a procedural PAPER bump so the wall has surface tooth: the
// warm window key grazes it and catches micro-relief, instead of reading as a
// flat decal pasted on cardboard. China Seas is hand screen-printed on paper.
function paperBumpTex(){
const c=document.createElement('canvas'); c.width=c.height=256;
const g=c.getContext('2d');
g.fillStyle='#808080'; g.fillRect(0,0,256,256);
const img=g.getImageData(0,0,256,256); const d=img.data;
for(let i=0;i<d.length;i+=4){ const n=128+(Math.random()-0.5)*46; // fine fibre grain
d[i]=d[i+1]=d[i+2]=n; }
g.putImageData(img,0,0);
// a few faint long fibres for a laid-paper feel
g.globalAlpha=0.05; g.strokeStyle='#fff';
for(let k=0;k<40;k++){ g.beginPath(); const y=Math.random()*256;
g.moveTo(0,y); g.lineTo(256,y+(Math.random()-0.5)*6); g.stroke(); }
const t=new THREE.CanvasTexture(c);
t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(8,8);
return t;
}
const PAPER_BUMP=paperBumpTex();
// wall material starts neutral; texture swapped in on load.
// vertexColors:true lets the baked corner-AO (dark toward edges) multiply the
// wallpaper map in-shader → soft corner occlusion with zero floating planes.
const wallMat = new THREE.MeshStandardMaterial({ color:0xece4d6, roughness:0.94, metalness:0.0,
side:THREE.FrontSide, vertexColors:true, bumpMap:PAPER_BUMP, bumpScale:0.012 });
// CONTRARIAN FIX — bake ambient occlusion into a wall's vertices: darken toward
// the two vertical corner seams and the floor/ceiling so the room reads as lit,
// not a flat-bright plastic box. Subdivided so the falloff is smooth. The AO is
// MODULATED by window proximity (windowFn returns 0..1 brightness boost) so the
// shade AGREES with the warm key light from the left-wall window instead of
// fighting it — corners near the window stay light, far corners go deeper.
function addCornerAO(geo, w, h, windowFn){
const pos=geo.attributes.position; const cols=[]; const col=new THREE.Color();
for(let i=0;i<pos.count;i++){
const x=pos.getX(i), y=pos.getY(i);
const fx=(x+w/2)/w, fy=(y+h/2)/h; // 0..1 across / up the wall
const edgeX=Math.min(fx,1-fx)*2; // 0 at vertical corner → 1 mid
const edgeY=Math.min(fy,1-fy)*2; // 0 at floor/ceil → 1 mid
const aoX=Math.max(0, 1-edgeX/0.34);
const aoY=Math.max(0, 1-edgeY/0.20);
let ao=Math.min(0.55, aoX*0.42 + aoY*0.30 + aoX*aoY*0.25);
if(windowFn){ ao *= windowFn(fx); } // lift AO near the window
const v=1-ao; col.setRGB(v,v,v); cols.push(col.r,col.g,col.b);
}
geo.setAttribute('color', new THREE.Float32BufferAttribute(cols,3));
}
// returns 0..1 AO strength across a wall given which local-x end faces the
// window (left wall). nearEnd in {0,1}: which fx is closest to the window.
function windowAO(nearEnd){
return (fx)=>{
const distToWindow = nearEnd===0 ? fx : (1-fx); // 0 at window end
return THREE.MathUtils.clamp(0.35 + distToWindow*0.95, 0.35, 1.0);
};
}
function wall(w, h, windowFn){
const g = new THREE.PlaneGeometry(w, h, 24, 24);
addCornerAO(g, w, h, windowFn);
const m = wallMat.clone();
m.userData.wallW = w; // physical width of this wall (m)
return new THREE.Mesh(g, m);
}
const walls = [];
// back wall (-Z): local +x → world +x, so fx=0 (world -W/2) is the window side
let m = wall(W, H, windowAO(0)); m.position.set(0, H/2, -D/2); walls.push(m);
// front wall (+Z, rotated π): local +x → world -x, so fx=1 is the window side
m = wall(W, H, windowAO(1)); m.position.set(0, H/2, D/2); m.rotation.y = Math.PI; walls.push(m);
// left wall (-X) = THE WINDOW WALL: keep it bright (window light washes it)
m = wall(D, H, ()=>0.30); m.position.set(-W/2, H/2, 0); m.rotation.y = Math.PI/2; walls.push(m);
// right wall (+X) = farthest from the window: full AO
m = wall(D, H, ()=>1.0); m.position.set(W/2, H/2, 0); m.rotation.y = -Math.PI/2; walls.push(m);
walls.forEach(w => { w.receiveShadow = true; scene.add(w); });
// ceiling — soft off-white
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D),
new THREE.MeshStandardMaterial({ color:0xf3ece0, roughness:1 }));
ceil.rotation.x = Math.PI/2; ceil.position.y = H; ceil.receiveShadow = true; scene.add(ceil);
// floor — warm wood tone
const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, D),
new THREE.MeshStandardMaterial({ color:0x6b4a31, roughness:0.78 }));
floor.rotation.x = -Math.PI/2; floor.position.y = 0; floor.receiveShadow = true; scene.add(floor);
// baseboard + crown trim (white) to frame the paper like a real install
const trimMat = new THREE.MeshStandardMaterial({ color:0xf6f1e8, roughness:0.6 });
function trimRing(y, h){
const grp = new THREE.Group();
const t = 0.02;
const spec = [
[W, 0, -D/2, 0], [W, 0, D/2, 0],
[D, -W/2, 0, Math.PI/2], [D, W/2, 0, Math.PI/2],
];
spec.forEach(([len,x,z,ry])=>{
const b = new THREE.Mesh(new THREE.BoxGeometry(len, h, t+0.01), trimMat);
b.position.set(x, y, z); b.rotation.y = ry; b.castShadow=false; grp.add(b);
});
return grp;
}
scene.add(trimRing(0.05, 0.10)); // baseboard
scene.add(trimRing(H-0.06, 0.12)); // crown
// ================= WINDOW (light source, on left wall) =================
(function(){
const ww = 1.6, wh = 1.5, wy = 1.25, wx = -W/2 + 0.012;
// bright daylight panel (emissive so it reads as a window)
const glass = new THREE.Mesh(new THREE.PlaneGeometry(ww, wh),
new THREE.MeshStandardMaterial({ color:0xfdf6e8, emissive:0xfff2d6, emissiveIntensity:1.2, roughness:1 }));
glass.position.set(wx, wy, -D*0.10); glass.rotation.y = Math.PI/2; scene.add(glass);
// white frame
const fr = new THREE.Mesh(new THREE.PlaneGeometry(ww+0.16, wh+0.16),
new THREE.MeshStandardMaterial({ color:0xf6f1e8, roughness:0.5 }));
fr.position.set(wx+0.002, wy, -D*0.10); fr.rotation.y = Math.PI/2; scene.add(fr);
// mullions
const mull = new THREE.MeshStandardMaterial({ color:0xeae3d6 });
const v = new THREE.Mesh(new THREE.BoxGeometry(0.03, wh, 0.03), mull);
v.position.set(wx-0.01, wy, -D*0.10); scene.add(v);
const hbar = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.03, ww), mull);
hbar.position.set(wx-0.01, wy, -D*0.10); scene.add(hbar);
})();
// ================= CONTACT SHADOWS + CORNER AO (CONTRARIAN FIX) =========
// Real photos read as real because objects are anchored to the floor by a soft
// contact shadow, and room corners fall into ambient-occlusion darkness. The
// hardware shadow map alone leaves a "plastic box" look (hard-edged, no corner
// falloff), so we bake soft radial/edge gradients and lay them over the floor.
function radialShadowTex(soft){
const c=document.createElement('canvas'); c.width=c.height=256;
const g=c.getContext('2d');
const grd=g.createRadialGradient(128,128,8, 128,128,128);
grd.addColorStop(0,`rgba(0,0,0,${soft?0.42:0.6})`);
grd.addColorStop(0.55,`rgba(0,0,0,${soft?0.18:0.28})`);
grd.addColorStop(1,'rgba(0,0,0,0)');
g.fillStyle=grd; g.fillRect(0,0,256,256);
const t=new THREE.CanvasTexture(c);
if('colorSpace' in t) t.colorSpace=THREE.SRGBColorSpace;
return t;
}
const SHADOW_TEX = radialShadowTex(false);
function contactShadow(x,z,w,d,opacity){
const m=new THREE.Mesh(new THREE.PlaneGeometry(w,d),
new THREE.MeshBasicMaterial({ map:SHADOW_TEX, transparent:true,
opacity:opacity==null?0.85:opacity, depthWrite:false,
blending:THREE.MultiplyBlending, polygonOffset:true, polygonOffsetFactor:-2 }));
m.rotation.x=-Math.PI/2; m.position.set(x,0.006,z); m.renderOrder=2; scene.add(m);
return m;
}
// Floor perimeter AO — a soft floor overlay darkening toward the floor/wall
// seams. Kept GENTLE (low opacity, short reach) so it does NOT stack with the
// furniture contact shadows into a crushed-black wedge — the combined floor
// darkness must read as soft shade, never a void.
(function cornerAO(){
const c=document.createElement('canvas'); c.width=c.height=512;
const g=c.getContext('2d');
g.fillStyle='rgba(0,0,0,0)'; g.fillRect(0,0,512,512);
// four edge gradients darkening inward from each wall (softer + shorter reach)
const edge=(x0,y0,x1,y1)=>{ const gr=g.createLinearGradient(x0,y0,x1,y1);
gr.addColorStop(0,'rgba(0,0,0,0.26)'); gr.addColorStop(0.30,'rgba(0,0,0,0.07)');
gr.addColorStop(1,'rgba(0,0,0,0)'); g.fillStyle=gr; g.fillRect(0,0,512,512); };
edge(0,0,150,0); edge(512,0,362,0); edge(0,0,0,150); edge(0,512,0,362);
const t=new THREE.CanvasTexture(c);
if('colorSpace' in t) t.colorSpace=THREE.SRGBColorSpace;
const ao=new THREE.Mesh(new THREE.PlaneGeometry(W,D),
new THREE.MeshBasicMaterial({ map:t, transparent:true, opacity:0.7,
depthWrite:false, blending:THREE.MultiplyBlending }));
ao.rotation.x=-Math.PI/2; ao.position.set(0,0.003,0); ao.renderOrder=1; scene.add(ao);
})();
// Wall-corner AO is baked directly into the wall geometry as VERTEX COLORS
// (see addCornerAO below, applied when each wall is built) — darkening toward
// the vertical corner seams + the floor/ceiling edges. This multiplies with the
// wallpaper texture in-shader, so there are NO floating planes / black-bar
// artifacts; the corners simply fall into soft shade like a real lit room.
// ================= FURNITURE silhouette =================
(function sofa(){
const grp = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color:0xcfc4b0, roughness:0.95 });
const base = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.45, 0.95), mat);
base.position.y = 0.32; base.castShadow = true; base.receiveShadow=true; grp.add(base);
const back = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.7, 0.22), mat);
back.position.set(0, 0.62, -0.36); back.castShadow=true; grp.add(back);
const seat = new THREE.Mesh(new THREE.BoxGeometry(2.05, 0.18, 0.78), new THREE.MeshStandardMaterial({color:0xe7ddc8, roughness:1}));
seat.position.set(0, 0.58, 0.04); seat.castShadow=true; grp.add(seat);
[-1.0,1.0].forEach(x=>{ const a=new THREE.Mesh(new THREE.BoxGeometry(0.2,0.6,0.95),mat); a.position.set(x,0.45,0); a.castShadow=true; grp.add(a); });
const sofaZ = -D/2 + 0.78;
grp.position.set(0, 0, sofaZ); // against the back wall
grp.rotation.y = 0;
scene.add(grp);
// CONTRARIAN FIX — soft contact shadow grounding the sofa to the floor so it
// doesn't float like a plastic prop. Kept soft (0.6) so it doesn't stack with
// the floor perimeter AO into a crushed-black wedge; offset forward of the
// back wall so the two shadows sit side-by-side, not on top of each other.
contactShadow(0, sofaZ+0.30, 3.0, 1.5, 0.6);
// round rug
const rug = new THREE.Mesh(new THREE.CircleGeometry(1.7, 48),
new THREE.MeshStandardMaterial({ color:0xb9a88c, roughness:1 }));
rug.rotation.x = -Math.PI/2; rug.position.set(0, 0.012, -D/2 + 1.7); rug.receiveShadow=true; scene.add(rug);
// soft shadow seating the rug onto the floor
contactShadow(0, -D/2+1.9, 3.9, 3.9, 0.4);
// a floor lamp by the sofa (gives the lamp glow a body)
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.02,0.02,1.5,12), new THREE.MeshStandardMaterial({color:0x3a3026}));
pole.position.set(W*0.32, 0.75, -D*0.30); scene.add(pole);
const shade = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.24,0.28,20),
new THREE.MeshStandardMaterial({color:0xfff1d8, emissive:0xffe6c0, emissiveIntensity:0.6, roughness:1}));
shade.position.set(W*0.32, 1.5, -D*0.30); scene.add(shade);
// contact shadow pooling under the floor lamp base
contactShadow(W*0.32, -D*0.30, 0.7, 0.7, 0.8);
})();
// ================= WALLPAPER TEXTURE =================
let designs = [];
let active = -1;
function applyTexture(t){
t.wrapS = t.wrapT = THREE.RepeatWrapping;
if ('colorSpace' in t) t.colorSpace = THREE.SRGBColorSpace;
else if ('encoding' in t) t.encoding = THREE.sRGBEncoding;
t.anisotropy = renderer.capabilities.getMaxAnisotropy();
// The tile image is one horizontal repeat (27") wide; its physical HEIGHT
// is 27" * (imgH/imgW). Respect that aspect so the pattern isn't squashed.
const iw = (t.image && t.image.width) || 2000;
const ih = (t.image && t.image.height) || 2500;
const tileW = REPEAT_M; // 27" wide
const tileH = REPEAT_M * (ih / iw); // proportional height
walls.forEach(w=>{
const tt = t.clone(); tt.needsUpdate = true;
tt.wrapS = tt.wrapT = THREE.RepeatWrapping;
if ('colorSpace' in tt) tt.colorSpace = THREE.SRGBColorSpace;
tt.anisotropy = t.anisotropy;
// repeats = wall_dimension / physical_tile_dimension
tt.repeat.set(w.material.userData.wallW / tileW, H / tileH);
w.material.map = tt;
w.material.color.set(0xffffff);
w.material.needsUpdate = true;
});
}
function pickDesign(i){
const d = designs[i]; if(!d) return;
active = i;
document.querySelectorAll('.swatch').forEach((el,k)=> el.classList.toggle('active', k===i));
document.getElementById('now').querySelector('b').textContent = d.pattern_name || d.sku;
document.getElementById('meta').textContent =
(d.collection || 'China Seas') + (d.repeat ? ' · ' + d.repeat + '" repeat' : '');
const url = d.tile || d.image;
tex.loader.load(url, applyTexture, undefined, (e)=>{
// fallback to the flat product image if tile fails
if (url !== d.image && d.image) tex.loader.load(d.image, applyTexture);
else console.warn('texture load failed', url, e);
});
}
function buildStrip(){
const strip = document.getElementById('strip');
strip.innerHTML = '';
designs.forEach((d,i)=>{
const el = document.createElement('div');
el.className = 'swatch';
el.innerHTML = '<img alt="" src="'+(d.tile||d.image)+'"><span>'+
((d.pattern_name||d.sku).split(/[-–]/)[0].trim())+'</span>';
el.addEventListener('click', ()=> pickDesign(i));
strip.appendChild(el);
});
}
// ---- boot ----
fetch('/api/showroom/products?limit=24')
.then(r=>r.json())
.then(j=>{
const list = (j.products||j||[]).filter(p=> p.tile || p.image);
designs = list.slice(0, 18);
if (!designs.length) throw new Error('no designs');
buildStrip();
// preload first then reveal
const first = designs[0];
tex.loader.load(first.tile || first.image, (t)=>{
applyTexture(t);
active = 0;
pickDesign(0);
reveal();
}, undefined, ()=>{ pickDesign(0); reveal(); });
})
.catch(err=>{
console.error('boot error', err);
// still show the room with the neutral wall so it's not a black screen
reveal();
});
function reveal(){
const ld = document.getElementById('loading');
ld.classList.add('gone');
const hint = document.getElementById('hint');
hint.classList.add('show');
setTimeout(()=> hint.classList.remove('show'), 4200);
setTimeout(()=> ld.remove(), 700);
}
// ---- resize ----
window.addEventListener('resize', ()=>{
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ---- render loop ----
let t0 = performance.now();
function tick(){
requestAnimationFrame(tick);
controls.update();
lockPivot(); // keep first-person pivot at the eye
renderer.render(scene, camera);
}
tick();
// expose for verification
window.__walkin = {
ready: ()=> active >= 0 && walls[0].material.map != null,
designs: ()=> designs.length,
active: ()=> active,
};
})();
</script>
<script>window.PROTO_META={"key":"V9","label":"V9 — Walk-In Room","elements":[{"n":1,"label":"Stand INSIDE a 1:1 room fully papered in the design"},{"n":2,"label":"Look around (drag) from the centre of the room"},{"n":3,"label":"Switch the wallpaper to any design"},{"n":4,"label":"Now-viewing pattern + colorway read-out"},{"n":5,"label":"Real room scale (window, floor, ceiling)"}]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>