From 5551963ef458b5ba15eeda2a1f56456488e53769 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 27 Jun 2026 12:47:11 +1000 Subject: [PATCH] =?UTF-8?q?feat(3d):=20procedural=20cyclorama=20/=20infini?= =?UTF-8?q?ty=20cove=20(new=20=E2=80=94=20not=20in=20WowPlatter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hip-hop room's north section is a real cyclorama (white curved infinity wall). Added a data-driven cove: virtual_space gains cyclorama (comma list of north/south/east/west) + cyclorama_radius + cyclorama_color. buildCyclorama() sweeps a profile per wall — [flat wall down to R] → [quarter-circle fillet radius R] → [white floor lead-in] — so the floor curves up into the wall with no hard seam (the seamless studio look). Cyclorama walls skip their flat wall (the cove replaces it). Set on hiphop-room (space 3): cyclorama=north, R=0.9. v1 = single-wall cove (back curve); 3-sided + rounded vertical corners + partial-wall extent for the L-shape = next iteration, all tunable via the columns. Co-Authored-By: Claude Opus 4.8 --- app/main.py | 4 ++++ webstore/index.html | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/main.py b/app/main.py index 5a91b6e..4a08dc5 100644 --- a/app/main.py +++ b/app/main.py @@ -119,6 +119,10 @@ _STARTUP_DDL = [ "median_au numeric(10,2), discogs_seller_count int, discogs_lowest numeric(10,2), " "already_stocked boolean DEFAULT false, updated_at timestamptz NOT NULL DEFAULT now(), " "PRIMARY KEY (store_id, release_id))", + # 3D store: cyclorama / infinity-cove walls (comma list of north/south/east/west) + fillet radius + "ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama text", + "ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_radius numeric", + "ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_color text", ] diff --git a/webstore/index.html b/webstore/index.html index 064831e..06ea627 100644 --- a/webstore/index.html +++ b/webstore/index.html @@ -123,6 +123,36 @@ function slopedSide(x, ch, cd, hf, th, m) { geom.setIndex([0, 1, 2, 2, 1, 3]); geom.computeVertexNormals(); return new THREE.Mesh(geom, m); } +// Cyclorama / infinity cove — the floor curves up into the named wall(s) via a fillet of radius R, +// with a white floor lead-in, so there's no hard floor↔wall seam (the seamless studio look). Each +// wall gets one swept profile: [wall down to R] → [quarter-circle fillet] → [flat floor lead-in]. +function buildCyclorama(walls, R, lead, W, D, H, mat) { + const prof = []; + for (let i = 0; i <= 6; i++) prof.push([0, H - (H - R) * i / 6]); // wall: u=0, v H→R + for (let i = 1; i <= 12; i++) { const t = Math.PI + (Math.PI / 2) * i / 12; prof.push([R + R * Math.cos(t), R + R * Math.sin(t)]); } // fillet R→0 + for (let i = 1; i <= 3; i++) prof.push([R + lead * i / 3, 0]); // floor lead-in + prof.forEach(p => { p[1] += 0.004; }); // lift a hair off the floor (no z-fight) + const P = prof.length, grp = new THREE.Group(); + const maps = { + north: { len: W, f: (s, u, v) => [s, v, -D / 2 + u] }, + south: { len: W, f: (s, u, v) => [s, v, D / 2 - u] }, + west: { len: D, f: (s, u, v) => [-W / 2 + u, v, s] }, + east: { len: D, f: (s, u, v) => [W / 2 - u, v, s] }, + }; + walls.forEach(wall => { + const mp = maps[wall]; if (!mp) return; + const pos = []; + [-mp.len / 2, mp.len / 2].forEach(s => prof.forEach(([u, v]) => { const p = mp.f(s, u, v); pos.push(p[0], p[1], p[2]); })); + const idx = []; + for (let p = 0; p < P - 1; p++) idx.push(p, P + p, p + 1, p + 1, P + p, P + p + 1); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pos), 3)); + geo.setIndex(idx); geo.computeVertexNormals(); + grp.add(new THREE.Mesh(geo, mat)); + }); + return grp; +} + // a decal's mesh (image plane or canvas-text plane) — shared by wall, crate-logo + rack decals. function makeDecalMesh(dec) { const w = num(dec.width, 0.2), h = num(dec.height, 0.2), op = num(dec.opacity, 1); @@ -212,12 +242,21 @@ function buildScene(data) { const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D), material(sp.ceiling_color, '#1a1a1f')); ceil.rotation.x = Math.PI / 2; ceil.position.y = H; roomGroup.add(ceil); const wallMat = material(sp.wall_color, '#3a3a42'); + const cyc = String(sp.cyclorama || '').toLowerCase().split(',').map(s => s.trim()).filter(Boolean); + const wallNames = ['north', 'south', 'west', 'east']; const walls = [[0, -D / 2, 0], [0, D / 2, Math.PI], [-W / 2, 0, Math.PI / 2], [W / 2, 0, -Math.PI / 2]]; walls.forEach(([x, z, ry], i) => { + if (cyc.includes(wallNames[i])) return; // a cyclorama wall is replaced by the curved cove const w = (i < 2) ? W : D; const m = new THREE.Mesh(new THREE.PlaneGeometry(w, H), wallMat.clone()); m.position.set(x, H / 2, z); m.rotation.y = ry; roomGroup.add(m); }); + if (cyc.length) { + const R = num(sp.cyclorama_radius, 0.9); + const cmat = material(sp.cyclorama_color || '#ffffff', '#ffffff'); + cmat.side = THREE.DoubleSide; cmat.roughness = 0.92; cmat.metalness = 0; + roomGroup.add(buildCyclorama(cyc, R, Math.max(R + 0.3, 1.2), W, D, H, cmat)); + } // lights (data-driven + a soft ambient so it's never pitch black) roomGroup.add(new THREE.AmbientLight(0xffffff, num(sp.ambient_light_intensity, 0.6)));