feat(3d): procedural cyclorama / infinity cove (new — not in WowPlatter)

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-27 12:47:11 +10:00
parent 205967d506
commit 5551963ef4
2 changed files with 43 additions and 0 deletions

View File

@ -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",
]

View File

@ -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)));