← back to Quadrille Showroom
proto/v9-walkin.html
368 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.20);
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';
// wall material starts neutral; texture swapped in on load
const wallMat = new THREE.MeshStandardMaterial({ color:0xece4d6, roughness:0.92, metalness:0.0, side:THREE.FrontSide });
function wall(w, h){
const g = new THREE.PlaneGeometry(w, h);
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)
let m = wall(W, H); m.position.set(0, H/2, -D/2); walls.push(m);
// front wall (+Z) faces inward
m = wall(W, H); m.position.set(0, H/2, D/2); m.rotation.y = Math.PI; walls.push(m);
// left wall (-X)
m = wall(D, H); m.position.set(-W/2, H/2, 0); m.rotation.y = Math.PI/2; walls.push(m);
// right wall (+X)
m = wall(D, H); 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);
})();
// ================= 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); });
grp.position.set(0, 0, -D/2 + 0.78); // against the back wall
grp.rotation.y = 0;
scene.add(grp);
// 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);
// 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);
})();
// ================= 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>
</body>
</html>