import * as THREE from 'three'; import { scene, mat, cyl, sph } from './engine.js'; import { buildProductMesh } from './products3d.js'; import { FURN_BY_ID } from '../data/furniture.js'; import G from '../sim/state.js'; // ============================================================ // FURNITURE 3D — meshes for every catalog item + shelf stocking // ============================================================ export const furnGroups = new Map(); // furnitureId -> {group, sig, slots:[]} export const WOOD = { dark: 0x6b4a2c, mid: 0x8a5a33, light: 0xa9713d }; export function cellToWorld(cx, cz) { const [W, H] = G.gridSize(); return [cx - W / 2 + 0.5, cz - H / 2 + 0.5]; } // Rebuild all furniture visuals (on load / expand / layout change) export function refreshAllFurniture() { if (!G.data) return; const seen = new Set(); for (const f of G.data.furniture) { seen.add(f.id); let rec = furnGroups.get(f.id); if (!rec) { const group = buildFurnMesh(f.type); scene.add(group); rec = { group, sig: '' }; furnGroups.set(f.id, rec); } placeFurn(rec.group, f); // shelf stock display if (['shelf', 'table', 'pedestal'].includes(f.type)) { const stock = G.data.shelfStock[f.id] || {}; const sig = JSON.stringify(stock); if (sig !== rec.sig) { rec.sig = sig; restockShelfVisual(f, rec); } } } for (const [id, rec] of [...furnGroups]) { if (!seen.has(id)) { scene.remove(rec.group); disposeDeep(rec.group); furnGroups.delete(id); } } } export function getFurnGroup(id) { return furnGroups.get(id)?.group || null; } function disposeDeep(obj) { obj.traverse(o => { o.geometry?.dispose(); }); } function placeFurn(group, f) { const def = FURN_BY_ID[f.type]; const rot = f.rot % 2; const w = rot ? def.size[1] : def.size[0]; const d = rot ? def.size[0] : def.size[1]; const [wx, wz] = cellToWorld(f.cx + w / 2 - 0.5, f.cz + d / 2 - 0.5); group.position.set(wx, 0, wz); group.rotation.y = -f.rot * Math.PI / 2; } // ---------- individual builders ---------- export function buildFurnMesh(type) { switch (type) { case 'shelf': return buildShelf(); case 'counter': return buildCounter(); case 'table': return buildTable(); case 'crate': return buildCrate(); case 'rug': return buildRug(); case 'plant': return buildPlant(); case 'lamp': return buildLampFurn(); case 'pedestal': return buildPedestal(); case 'painting': return buildPainting(); case 'banner': return buildBanner(); case 'fountain': return buildMiniFountain(); case 'catbed': return buildCatBed(); default: return new THREE.Group(); } } function shadowify(g) { g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); return g; } function buildShelf() { const g = new THREE.Group(); const sideM = mat(WOOD.mid, { rough: .85 }); const boardM = mat(WOOD.light, { rough: .85 }); // vertical sides for (const sgn of [-1, 1]) { const side = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.7, 0.42), sideM); side.position.set(sgn * 0.96, 0.85, 0); g.add(side); } // boards const levels = [0.12, 0.66, 1.2]; for (const y of levels) { const board = new THREE.Mesh(new THREE.BoxGeometry(1.92, 0.06, 0.44), boardM); board.position.y = y; g.add(board); } // top crown const crown = new THREE.Mesh(new THREE.BoxGeometry(2.04, 0.09, 0.48), mat(WOOD.dark)); crown.position.y = 1.72; g.add(crown); // back panel const back = new THREE.Mesh(new THREE.BoxGeometry(1.92, 1.68, 0.04), mat(0x9a6b3f, { rough: .95 })); back.position.set(0, 0.9, -0.21); g.add(back); // little heart carved on top const h = new THREE.Mesh(sph(0.05, 8), mat(0xef6461)); h.position.set(0, 1.82, 0); g.add(h); return shadowify(g); } function shelfSlots() { // 4 per level × 3 levels, world-local positions const slots = []; for (const y of [0.24, 0.78, 1.32]) { for (let i = 0; i < 4; i++) { slots.push(new THREE.Vector3(-0.69 + i * 0.46, y, 0)); } } return slots; } function buildCounter() { const g = new THREE.Group(); const body = new THREE.Mesh(new THREE.BoxGeometry(1.95, 0.95, 0.75), mat(WOOD.dark, { rough: .85 })); body.position.y = 0.48; g.add(body); const top = new THREE.Mesh(new THREE.BoxGeometry(2.08, 0.09, 0.88), mat(WOOD.light, { rough: .7 })); top.position.y = 1.0; g.add(top); // till / register const till = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.3, 0.3), mat(0x4a4038, { rough: .6 })); till.position.set(-0.6, 1.2, 0); g.add(till); const keys = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.03, 0.22), mat(0xd9c9a8)); keys.position.set(-0.6, 1.36, 0.02); g.add(keys); // bell const bell = new THREE.Mesh(sph(0.06, 10), mat(0xffd166, { metal: .8, rough: .25 })); bell.scale.y = 0.85; bell.position.set(0.15, 1.14, 0.15); g.add(bell); const btn = new THREE.Mesh(sph(0.02, 6), mat(0x4a4038)); btn.position.set(0.15, 1.23, 0.15); g.add(btn); // jar of lollipops for charm const jar = new THREE.Mesh(cyl(0.09, 0.09, 0.16, 10), new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true, opacity: .35, roughness: .2 })); jar.position.set(0.62, 1.13, 0); g.add(jar); for (let i = 0; i < 4; i++) { const pop = new THREE.Mesh(sph(0.045, 8), mat([0xef6461, 0x58c98b, 0x5aa9e6, 0xffd166][i])); pop.position.set(0.56 + (i % 2) * 0.11, 1.16 + Math.floor(i / 2) * 0.07, (i % 2 ? 0.03 : -0.03)); g.add(pop); } // front panel trim const trim = new THREE.Mesh(new THREE.BoxGeometry(1.98, 0.1, 0.78), mat(WOOD.mid)); trim.position.y = 0.12; g.add(trim); return shadowify(g); } function buildTable() { const g = new THREE.Group(); const top = new THREE.Mesh(cyl(0.95, 0.95, 0.09, 18), mat(WOOD.light, { rough: .75 })); top.position.y = 0.72; g.add(top); const cloth = new THREE.Mesh(cyl(0.99, 0.99, 0.03, 18), mat(0xf6ead2, { rough: .95 })); cloth.position.y = 0.77; g.add(cloth); const leg = new THREE.Mesh(cyl(0.12, 0.17, 0.7, 10), mat(WOOD.mid)); leg.position.y = 0.36; g.add(leg); const base = new THREE.Mesh(cyl(0.42, 0.46, 0.08, 14), mat(WOOD.dark)); base.position.y = 0.04; g.add(base); return shadowify(g); } function tableSlots() { const s = []; for (let i = 0; i < 6; i++) { const ang = (i / 6) * Math.PI * 2 + 0.5; s.push(new THREE.Vector3(Math.cos(ang) * 0.52, 0.83, Math.sin(ang) * 0.52)); } return s; } function buildCrate() { const g = new THREE.Group(); const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.62, 0.8), mat(WOOD.mid, { rough: .95 })); box.position.y = 0.31; g.add(box); const edge = mat(WOOD.dark, { rough: .95 }); for (const [rx, ry] of [[0, 0], [0, 0.62]]) { const band = new THREE.Mesh(new THREE.BoxGeometry(0.84, 0.07, 0.84), edge); band.position.y = ry === 0 ? 0.05 : 0.57; g.add(band); } const cross = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.1, 0.1), edge); cross.rotation.x = Math.PI / 4; cross.position.y = 0.31; cross.scale.z = 6; g.add(cross); return shadowify(g); } function buildRug() { const cv = document.createElement('canvas'); cv.width = cv.height = 128; const ctx = cv.getContext('2d'); ctx.fillStyle = '#c95f4a'; ctx.fillRect(0, 0, 128, 128); ctx.strokeStyle = '#f6ead2'; ctx.lineWidth = 6; ctx.strokeRect(10, 10, 108, 108); ctx.strokeStyle = '#ffd166'; ctx.lineWidth = 3; ctx.strokeRect(20, 20, 88, 88); ctx.fillStyle = '#f6ead2'; for (let i = 0; i < 4; i++) { ctx.beginPath(); ctx.arc(38 + i * 18, 64, 5, 0, 7); ctx.fill(); } const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; const m = new THREE.Mesh( new THREE.BoxGeometry(2.9, 0.03, 1.9), new THREE.MeshStandardMaterial({ map: tex, roughness: 1 }) ); m.position.y = 0.02; m.receiveShadow = true; return m; } function buildPlant() { const g = new THREE.Group(); const pot = new THREE.Mesh(cyl(0.19, 0.14, 0.3, 10), mat(0xc96f52, { rough: .9 })); pot.position.y = 0.15; g.add(pot); const rim = new THREE.Mesh(cyl(0.21, 0.21, 0.06, 10), mat(0xb85f43)); rim.position.y = 0.3; g.add(rim); const stem = new THREE.Mesh(cyl(0.025, 0.03, 0.35, 6), mat(0x4f8a44)); stem.position.y = 0.5; g.add(stem); const leafM = mat(0x5da24b, { rough: .95 }); for (let i = 0; i < 5; i++) { const leaf = new THREE.Mesh(sph(rand(.14, .22), 8), leafM); leaf.scale.set(1, 0.55, 1); const ang = (i / 5) * Math.PI * 2; leaf.position.set(Math.cos(ang) * 0.16, 0.62 + (i % 2) * 0.14, Math.sin(ang) * 0.16); g.add(leaf); } function rand(a = 0.8, b) { return b === undefined ? Math.random() * a : a + Math.random() * (b - a); } return shadowify(g); } let lampLightCount = 0; function buildLampFurn() { const g = new THREE.Group(); const pole = new THREE.Mesh(cyl(0.035, 0.05, 1.5, 8), mat(0x4a4038, { metal: .3, rough: .5 })); pole.position.y = 0.75; g.add(pole); const base = new THREE.Mesh(cyl(0.2, 0.24, 0.06, 12), mat(0x4a4038)); base.position.y = 0.03; g.add(base); const shade = new THREE.Mesh(cone(0.28, 0.32), new THREE.MeshStandardMaterial({ color: 0xffd9a0, emissive: 0xffb45e, emissiveIntensity: 0.9, roughness: .8 })); shade.position.y = 1.62; g.add(shade); const bulb = new THREE.Mesh(sph(0.07, 8), mat(0xfff3cf, { emissive: 0xffdf9e, emissiveIntensity: 2.2 })); bulb.position.y = 1.52; g.add(bulb); // real light only for first few lamps (perf) if (lampLightCount < 4) { const pl = new THREE.PointLight(0xffc46b, 6, 6.5, 1.8); pl.position.y = 1.5; g.add(pl); lampLightCount++; } return shadowify(g); } function cone(r, h) { return new THREE.ConeGeometry(r, h, 12); } function buildPedestal() { const g = new THREE.Group(); const marble = mat(0xe8e2d5, { rough: .5 }); const base = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.12, 0.55), marble); base.position.y = 0.06; g.add(base); const column = new THREE.Mesh(cyl(0.16, 0.2, 0.9, 10), marble); column.position.y = 0.55; g.add(column); const cap = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.08, 0.45), marble); cap.position.y = 1.03; g.add(cap); // glass dome const dome = new THREE.Mesh(sph(0.26, 14), new THREE.MeshStandardMaterial({ color: 0xcfe8f5, transparent: true, opacity: 0.18, roughness: .1, metalness: .1 })); dome.scale.y = 1.25; dome.position.y = 1.32; g.add(dome); return shadowify(g); } function pedestalSlot() { return new THREE.Vector3(0, 1.1, 0); } function buildPainting() { const g = new THREE.Group(); const frame = new THREE.Mesh(new THREE.BoxGeometry(0.95, 0.75, 0.08), mat(0xb98a3d, { rough: .6, metal: .3 })); frame.position.y = 1.9; g.add(frame); // tiny procedural landscape const cv = document.createElement('canvas'); cv.width = 128; cv.height = 96; const ctx = cv.getContext('2d'); const grad = ctx.createLinearGradient(0, 0, 0, 96); grad.addColorStop(0, '#8ecdea'); grad.addColorStop(1, '#ffe3ae'); ctx.fillStyle = grad; ctx.fillRect(0, 0, 128, 96); ctx.fillStyle = '#7cb95a'; ctx.beginPath(); ctx.moveTo(0, 70); ctx.lineTo(40, 40); ctx.lineTo(80, 74); ctx.lineTo(128, 50); ctx.lineTo(128, 96); ctx.lineTo(0, 96); ctx.fill(); ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.arc(96, 26, 10, 0, 7); ctx.fill(); const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; const art = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 0.6), new THREE.MeshBasicMaterial({ map: tex })); art.position.set(0, 1.9, 0.045); g.add(art); g.userData.wallArt = true; return g; } function buildBanner() { const g = new THREE.Group(); const pole = new THREE.Mesh(cyl(0.035, 0.035, 2.4, 6), mat(0x6b4a2c)); pole.position.y = 1.2; g.add(pole); const clothColors = [0xef6461, 0xffd166, 0x5aa9e6]; for (let i = 0; i < 3; i++) { const flag = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.36, 4), new THREE.MeshStandardMaterial({ color: clothColors[i], roughness: .9, side: THREE.DoubleSide })); flag.rotation.z = Math.PI; flag.position.set(0.001, 2.1 - i * 0.42, 0); g.add(flag); } return shadowify(g); } function buildMiniFountain() { const g = new THREE.Group(); const stone = mat(0xa8adb5, { rough: .85 }); const basin = new THREE.Mesh(cyl(0.85, 0.95, 0.4, 16), stone); basin.position.y = 0.2; g.add(basin); const water = new THREE.Mesh(cyl(0.76, 0.76, 0.1, 16), mat(0x7fd4ea, { rough: .15 })); water.position.y = 0.42; water.name = 'water'; g.add(water); const pillar = new THREE.Mesh(cyl(0.12, 0.16, 0.6, 10), stone); pillar.position.y = 0.65; g.add(pillar); const bowl = new THREE.Mesh(cyl(0.3, 0.38, 0.12, 12), stone); bowl.position.y = 0.95; g.add(bowl); const water2 = new THREE.Mesh(cyl(0.26, 0.26, 0.06, 12), mat(0x7fd4ea, { rough: .15 })); water2.position.y = 1.02; g.add(water2); return shadowify(g); } function buildCatBed() { const g = new THREE.Group(); const bed = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.12, 8, 16), mat(0xef8fb5, { rough: 1 })); bed.rotation.x = Math.PI / 2; bed.scale.y = 0.6; bed.position.y = 0.1; g.add(bed); const cushion = new THREE.Mesh(cyl(0.3, 0.3, 0.08, 16), mat(0xf6d8e0, { rough: 1 })); cushion.position.y = 0.06; g.add(cushion); // sleeping cat (if adopted) if (G.data?.flags.cat) { const body = new THREE.Mesh(sph(0.2, 10), mat(0xe8a25c, { rough: 1 })); body.scale.set(1.25, 0.75, 1); body.position.y = 0.18; g.add(body); const head = new THREE.Mesh(sph(0.13, 10), mat(0xe8a25c, { rough: 1 })); head.position.set(0.24, 0.24, 0); g.add(head); for (const sgn of [-1, 1]) { const ear = new THREE.Mesh(cone(0.05, 0.09, 4), mat(0xd98f47)); ear.position.set(0.24 + sgn * 0.07, 0.37, 0); g.add(ear); } const tail = new THREE.Mesh(new THREE.TorusGeometry(0.12, 0.035, 6, 12, 4), mat(0xd98f47)); tail.position.set(-0.22, 0.12, 0.1); tail.rotation.x = 1.2; g.add(tail); } return shadowify(g); } // ---------- shelf stock visualization ---------- function slotsFor(type) { if (type === 'shelf') return shelfSlots(); if (type === 'table') return tableSlots(); return [pedestalSlot()]; } function restockShelfVisual(f, rec) { // remove old product meshes if (rec.items) for (const it of rec.items) it.removeFromParent(); rec.items = []; const stock = G.data.shelfStock[f.id] || {}; const slots = slotsFor(f.type); let slotIdx = 0; for (const [pid, qty] of Object.entries(stock)) { const n = Math.min(qty, f.type === 'pedestal' ? 1 : Math.ceil(qty / 3) + 1, slots.length - slotIdx); for (let i = 0; i < n && slotIdx < slots.length; i++, slotIdx++) { const m = buildProductMesh(pid, f.type === 'pedestal' ? 1.5 : 1); const s = slots[slotIdx]; m.position.copy(s).add(new THREE.Vector3((Math.random() - 0.5) * 0.06, 0, (Math.random() - 0.5) * 0.08)); m.rotation.y = Math.random() * Math.PI * 2; rec.group.add(m); rec.items.push(m); } } }