import { useEffect, useMemo, useRef } from 'react'; import * as THREE from 'three'; import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared'; import { rotatedAnchorOffsetToCenter, TEXTURABLE_FURNITURE } from '@house-plan-maker/shared'; import { getCurtainLeftOpen, getCurtainRightOpen, getCurtainFabricColor } from '../utils/curtainMetadata'; import { getFurnitureTexture } from '../utils/furnitureTextureMetadata'; import { getFurnitureSurfacePbr, computeTextureRepeat } from './utils/pbrTextures'; /** * Below this opacity threshold, furniture meshes stop casting shadows. Cast * shadows from near-invisible objects look like floating dark blobs because * the shadow map ignores material opacity, so the visual is jarring. 1% gives * users a clean "ghost" mode when they slide opacity to zero. */ const SHADOW_OPACITY_THRESHOLD = 0.01; interface FurnitureMeshProps { readonly item: FurnitureItem; readonly isSelected: boolean; readonly onSelect?: (id: string) => void; /** Global furniture opacity multiplier from the toolbar slider (0..1). */ readonly globalOpacity?: number; } const FURNITURE_COLORS: Record = { BED: '#8fa5b2', CRIB: '#f0e4d2', DESK: '#b08968', WARDROBE: '#7a6652', SOFA: '#9b8e7e', TABLE: '#c4a882', CHAIR: '#a0937d', OFFICE_CHAIR: '#2a2a30', SHELF: '#b09e8a', NIGHTSTAND: '#9b8b7a', DRESSER: '#8a7a6a', DRESSING_TABLE: '#c4a882', BOOKCASE: '#7a6a5a', TV: '#2a2a3a', PC_TOWER: '#2a2a33', AC_UNIT: '#e8e8e8', RADIATOR: '#e0e0dc', WALL_COLLAGE: '#3a2f1e', CURTAIN: '#e8dfc8', PLANT: '#4a7a3a', MIRROR: '#8a7a5c', DIGITAL_PIANO: '#1a1a22', SPEAKER: '#20201e', OTHER: '#a0a0a0', }; const SELECTED_COLOR = '#6fa8dc'; const LEG_COLOR = '#4a3728'; const LEG_RADIUS = 0.02; const LEG_SEGMENTS = 6; // ── Shared materials (module-level singletons) ── // // These are the *template* materials picked up by the inner mesh // components (BedMesh, DeskMesh, …). Every FurnitureMesh CLONES them at // mount time so each item can have its own opacity without affecting // others. Without the clone step the shared `legMaterial` would be // referenced by every bed frame in the scene — setting its `opacity` to // 0.3 on one bed would ghost every bed. const legMaterial = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.6 }); const legMaterialSmooth = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.5 }); const furnitureMaterials: Record = {}; function getFurnitureMaterial(color: string, roughness: number): THREE.MeshStandardMaterial { const key = `${color}_${roughness}`; const existing = furnitureMaterials[key]; if (existing) return existing; const mat = new THREE.MeshStandardMaterial({ color, roughness }); furnitureMaterials[key] = mat; return mat; } /** * Legacy no-op kept for backward compatibility with any external callers. * The previous implementation mutated the shared material singletons above * to express a scene-wide furniture opacity, which broke per-item opacity * because items share materials. Per-item cloning in FurnitureMesh now * handles both the per-item and global factors via a single effective * opacity product, so this function no longer needs to do anything. */ export function setFurnitureGlobalOpacity(_opacity: number): void { // intentionally empty — per-item material clones own their opacity now } /** * Returns a material for furniture surfaces: either a PBR textured material * (when the item has a surfaceTexture set) or the solid-color fallback. * The PBR material's UV repeat is scaled for the given surface dimensions. */ function useSurfaceMaterial( item: FurnitureItem, solidColor: string, solidRoughness: number, surfaceWidth: number, surfaceDepth: number, ): THREE.MeshStandardMaterial { return useMemo(() => { if (!TEXTURABLE_FURNITURE.includes(item.type)) { return getFurnitureMaterial(solidColor, solidRoughness); } const texture = getFurnitureTexture(item.metadata); const pbr = getFurnitureSurfacePbr(texture); if (!pbr) { return getFurnitureMaterial(solidColor, solidRoughness); } // Clone the PBR material so UV repeat is per-item, not global. const mat = pbr.material.clone(); const repeat = computeTextureRepeat(surfaceWidth, surfaceDepth, pbr.tileMeters); if (mat.map) { mat.map = mat.map.clone(); mat.map.repeat.set(repeat.u, repeat.v); } if (mat.normalMap) { mat.normalMap = mat.normalMap.clone(); mat.normalMap.repeat.set(repeat.u, repeat.v); } if (mat.roughnessMap) { mat.roughnessMap = mat.roughnessMap.clone(); mat.roughnessMap.repeat.set(repeat.u, repeat.v); } return mat; }, [item.type, item.metadata, solidColor, solidRoughness, surfaceWidth, surfaceDepth]); } // ── Shared geometries for common shapes ── const legGeometry = new THREE.CylinderGeometry(LEG_RADIUS, LEG_RADIUS, 1, LEG_SEGMENTS); const dividerLineGeometry = new THREE.BoxGeometry(0.005, 1, 0.002); function degToRad(degrees: number): number { return (degrees * Math.PI) / 180; } /** Simple bed: mattress box + headboard */ function BedMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const mattressHeight = item.height * 0.4; const frameHeight = item.height * 0.3; const headboardHeight = item.height; const mattressMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]); const frameMaterial = useSurfaceMaterial(item, LEG_COLOR, 0.6, item.width, item.depth); const headboardMaterial = useSurfaceMaterial(item, LEG_COLOR, 0.5, item.width, headboardHeight); return ( {/* Frame */} {/* Mattress */} {/* Headboard */} ); } /** Desk: top slab + 4 legs */ function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const topThickness = 0.04; const legHeight = item.height - topThickness; const inset = 0.05; const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth); return ( {/* Top */} {/* Legs */} {[ [-item.width / 2 + inset, -item.depth / 2 + inset], [item.width / 2 - inset, -item.depth / 2 + inset], [-item.width / 2 + inset, item.depth / 2 - inset], [item.width / 2 - inset, item.depth / 2 - inset], ].map(([x, z], i) => ( ))} ); } /** Wardrobe: tall box with slight door line */ function WardrobeMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const bodyMaterial = useSurfaceMaterial(item, color, 0.6, item.width, item.height); return ( {/* Door divider line */} ); } /** Sofa: seat + back (L-shape profile) */ function SofaMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const seatHeight = item.height * 0.45; const backHeight = item.height; const backDepth = item.depth * 0.25; const sofaMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]); return ( {/* Seat */} {/* Backrest */} {/* Armrests */} {[-1, 1].map((side) => ( ))} ); } /** Table: top slab + 4 legs */ function TableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const topThickness = 0.03; const legHeight = item.height - topThickness; const inset = 0.05; const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth); return ( {[ [-item.width / 2 + inset, -item.depth / 2 + inset], [item.width / 2 - inset, -item.depth / 2 + inset], [-item.width / 2 + inset, item.depth / 2 - inset], [item.width / 2 - inset, item.depth / 2 - inset], ].map(([x, z], i) => ( ))} ); } /** Chair: seat + back + 4 legs */ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const seatHeight = item.height * 0.5; const seatThickness = 0.03; const legHeight = seatHeight - seatThickness; const inset = 0.03; const chairMaterial = useSurfaceMaterial(item, color, 0.6, item.width, item.depth); return ( {/* Seat */} {/* Backrest */} {/* Legs */} {[ [-item.width / 2 + inset, -item.depth / 2 + inset], [item.width / 2 - inset, -item.depth / 2 + inset], [-item.width / 2 + inset, item.depth / 2 - inset], [item.width / 2 - inset, item.depth / 2 - inset], ].map(([x, z], i) => ( ))} ); } /** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */ function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const material = useSurfaceMaterial(item, color, 0.6, item.width, item.depth); return ( ); } /** * Bookcase: open shelves with a back panel and two sides. * * The number of shelf rows is read from `metadata.shelfRows` when * present. `shelfRows` counts the STORAGE COMPARTMENTS — so 3 rows * means 3 usable spaces which the mesh draws as 4 horizontal shelf * boards (top + bottom + two intermediate dividers). If the metadata * value is missing or invalid, the shelf count is derived from the * item's height (~one compartment every 35cm) so legacy bookcases * render unchanged. * * Variants via `metadata.hasBackPanel`: when explicitly `false`, the * back panel is omitted — used by the "Open Bookshelf" preset which * reads as a room divider / floating shelf unit rather than a cabinet * with a back. */ function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const metadataRows = item.metadata?.['shelfRows']; const rawRows = typeof metadataRows === 'number' && Number.isFinite(metadataRows) && metadataRows >= 1 ? Math.round(metadataRows) : Math.max(2, Math.round(item.height / 0.35)); // Clamp — 12 is plenty even for tall library units. const shelfRows = Math.max(1, Math.min(12, rawRows)); const hasBackPanelRaw = item.metadata?.['hasBackPanel']; const hasBackPanel = typeof hasBackPanelRaw === 'boolean' ? hasBackPanelRaw : true; const panelThickness = 0.02; const material = useSurfaceMaterial(item, color, 0.6, item.width, item.height); return ( {/* Back panel (optional). When omitted the bookshelf reads as an open unit that can be seen through from both sides. */} {hasBackPanel && ( )} {/* Side panels */} {[-1, 1].map((side) => ( ))} {/* Horizontal shelf boards. `shelfRows` compartments produce `shelfRows + 1` boards (top + bottom + internal dividers), spaced evenly along the full height. */} {Array.from({ length: shelfRows + 1 }).map((_, i) => { const y = (i / shelfRows) * item.height; return ( ); })} ); } /** TV: thin screen panel, optionally on a stand */ function TvMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const screenMaterial = useMemo(() => getFurnitureMaterial('#1a1a2e', 0.1), []); const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]); const screenThickness = 0.03; const hasStand = !item.label?.includes('[no-stand]'); const standHeight = hasStand ? item.height * 0.15 : 0; const screenHeight = item.height - standHeight; return ( {/* Screen */} {/* Frame border */} {hasStand && ( <> {/* Stand */} {/* Stand base */} )} ); } /** * Wall collage: a flat mounted panel divided into a grid of "photos" by * the dark frame. The grid count is derived from the aspect ratio so a * 1.5×0.6 panel reads as a 5×2 collage automatically. Each cell shows a * lightly tinted plane that stands in for a photograph; the user gets a * recognisable "photo wall" silhouette without us needing to ship images. */ function WallCollageMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { // Pick a grid that roughly matches the panel aspect ratio. Cells are // square-ish and bounded so they look like real photo frames. const aspect = item.width / item.height; const cols = Math.max(2, Math.min(6, Math.round(aspect * 2))); const rows = Math.max(1, Math.min(4, Math.round(cols / aspect))); const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]); // A small palette of warm photo-ish tints — picking via index keeps the // result deterministic per cell so it doesn't shimmer between renders. const photoMaterials = useMemo( () => [ getFurnitureMaterial('#b6c8d4', 0.4), getFurnitureMaterial('#d8c4a0', 0.4), getFurnitureMaterial('#a0b88a', 0.4), getFurnitureMaterial('#c8a0a0', 0.4), getFurnitureMaterial('#9aa0b8', 0.4), getFurnitureMaterial('#d0b890', 0.4), ], [], ); const panelDepth = Math.max(0.005, item.depth); const cellMargin = 0.01; // gap between photos within the frame const cellW = (item.width - cellMargin * (cols + 1)) / cols; const cellH = (item.height - cellMargin * (rows + 1)) / rows; return ( {/* Frame backing panel */} {/* Grid of photo cells, each slightly proud of the frame so the dark frame border is visible between them. */} {Array.from({ length: rows }).map((_, r) => Array.from({ length: cols }).map((_, c) => { const idx = r * cols + c; const localX = -item.width / 2 + cellMargin + cellW / 2 + c * (cellW + cellMargin); const localY = item.height - cellMargin - cellH / 2 - r * (cellH + cellMargin); return ( ); }), )} ); } /** * Radiator: a back panel covered by a row of vertical fins. Looks like a * standard panel-type room radiator from any angle. The fin count is * derived from the width so wider radiators read as having more sections. */ function RadiatorMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.45), [color]); // Radiators are typically ~10cm fin pitch. const finPitch = 0.1; const finCount = Math.max(3, Math.round(item.width / finPitch)); const finWidth = (item.width / finCount) * 0.65; const finDepth = item.depth * 0.85; // Back-plate sits flush against the wall (negative Z is "into the wall" // for wall-mounted items, but radiators are free-standing — keep the // panel centered along Z). const panelDepth = item.depth * 0.25; return ( {/* Back panel */} {/* Vertical fins */} {Array.from({ length: finCount }).map((_, i) => { const offsetX = -item.width / 2 + (i + 0.5) * (item.width / finCount); return ( ); })} {/* Top cap (the slotted grille typical of panel radiators) */} ); } /** * Curtain: a slim rod across the top with pleated fabric panels hanging * from it. Left and right panels are independently drawn aside via the * `leftOpen` and `rightOpen` metadata values (0 = fully closed, 1 = fully * retracted to the outer edge). A legacy symmetric `openAmount` field is * honoured as a fallback so older curtain rows keep their appearance. * * Rendering is identical to the symmetric version in structure — two * panels of pleated strips — except each panel consults its own open * value so the user can tie back just one side. */ function CurtainMesh({ item, color: _defaultColor }: { readonly item: FurnitureItem; readonly color: string }) { // Read state from metadata with safe defaults for legacy rows. const fabricColor = getCurtainFabricColor(item.metadata); const leftOpen = getCurtainLeftOpen(item.metadata); const rightOpen = getCurtainRightOpen(item.metadata); const fabricMaterial = useMemo(() => getFurnitureMaterial(fabricColor, 0.95), [fabricColor]); const rodMaterial = useMemo(() => getFurnitureMaterial('#8a7a5c', 0.4), []); // Rod sits just above the top of the curtain, 2cm thick. const rodRadius = 0.012; const rodY = item.height + 0.02; const fabricHeight = item.height; const panelDepth = 0.02; const pleatDepth = 0.025; // Total pleats across the full (closed) width; split into two equal halves. const totalPleats = Math.max(8, Math.round(item.width / 0.08)); const pleatsPerPanel = Math.max(1, Math.floor(totalPleats / 2)); // Each panel's visible width is derived from its own open value. A small // residual (~5% of half-width) keeps the bunched stack visible when a // panel is fully retracted instead of collapsing into zero-width geometry. const minPanelWidth = item.width * 0.05; const halfWidth = item.width / 2; const leftPanelWidth = Math.max(minPanelWidth, halfWidth * (1 - leftOpen)); const rightPanelWidth = Math.max(minPanelWidth, halfWidth * (1 - rightOpen)); const leftPleatWidth = leftPanelWidth / pleatsPerPanel; const rightPleatWidth = rightPanelWidth / pleatsPerPanel; const leftOuterX = -halfWidth; const rightOuterX = halfWidth; return ( {/* Horizontal rod runs the full width regardless of open state. */} {/* Rod finials (end caps) */} {[-1, 1].map((side) => ( ))} {/* Left panel pleats grow inward from the left outer edge. */} {Array.from({ length: pleatsPerPanel }).map((_, i) => { const localX = leftOuterX + (i + 0.5) * leftPleatWidth; const isFront = i % 2 === 0; const zOffset = isFront ? pleatDepth / 2 : -pleatDepth / 2; return ( ); })} {/* Right panel pleats grow inward from the right outer edge. */} {Array.from({ length: pleatsPerPanel }).map((_, i) => { const localX = rightOuterX - (i + 0.5) * rightPleatWidth; const isFront = i % 2 === 0; const zOffset = isFront ? pleatDepth / 2 : -pleatDepth / 2; return ( ); })} ); } /** * Crib (baby bed): solid floor panel with a mattress, surrounded by a * ring of vertical slats that give cribs their characteristic look. * Slat density scales with width so wider cribs get more rails without * looking sparse. */ function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const frameMaterial = useSurfaceMaterial(item, color, 0.55, item.width, item.depth); const mattressMaterial = useMemo(() => getFurnitureMaterial('#f4eadf', 0.9), []); const mattressThick = 0.08; const mattressY = 0.25; // top of the mattress sits just below cot top rail const mattressLift = mattressY; // Slats const slatRadius = 0.01; const slatHeight = item.height - mattressLift - 0.04; const slatsPerSide = Math.max(6, Math.round((item.width - 0.1) / 0.08)); const slatsPerEnd = Math.max(4, Math.round((item.depth - 0.1) / 0.08)); // Top rail, bottom rail, corner posts const railThick = 0.02; const cornerPosts = ([-1, 1] as const).flatMap((sx) => ([-1, 1] as const).map((sz) => ({ sx, sz })), ); return ( {/* Bottom rail ring (sits just above the mattress top) */} {/* Top rail ring */} {/* Corner posts */} {cornerPosts.map(({ sx, sz }) => ( ))} {/* Long-side slats (front + back) */} {([-1, 1] as const).map((sz) => Array.from({ length: slatsPerSide }).map((_, i) => { const localX = -item.width / 2 + railThick + (i + 0.5) * ((item.width - railThick * 2) / slatsPerSide); return ( ); }), )} {/* Short-end slats (left + right) */} {([-1, 1] as const).map((sx) => Array.from({ length: slatsPerEnd }).map((_, i) => { const localZ = -item.depth / 2 + railThick + (i + 0.5) * ((item.depth - railThick * 2) / slatsPerEnd); return ( ); }), )} {/* Mattress */} ); } /** * Dressing table (vanity): a desk-like base with drawers and an upright * mirror mounted at the back. Depth is typically shallow (~40cm) and the * mirror takes up roughly half the total height. Height of the desk slab * is derived as a fraction of the total so the mirror always reads as an * upright panel. */ function DressingTableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const bodyMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth); const mirrorMaterial = useMemo(() => getFurnitureMaterial('#b8d0d8', 0.05), []); const mirrorFrameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]); const deskHeight = Math.min(0.8, item.height * 0.45); const mirrorHeight = item.height - deskHeight; const mirrorWidth = item.width * 0.7; const mirrorThickness = 0.015; const frameThickness = 0.025; return ( {/* Desk body — a closed box representing drawers */} {/* Drawer divider lines on the front face */} {/* Mirror frame (slightly larger than the mirror itself) */} {/* Mirror surface */} ); } /** * PC tower (desktop computer case): a tall rectangular box with a subtle * front-panel accent and a small power LED. Depth > width > short so it * reads as a standing tower regardless of exact dimensions. */ function PcTowerMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.45), [color]); const accentMaterial = useMemo(() => getFurnitureMaterial('#141418', 0.3), []); const ledMaterial = useMemo(() => { const mat = new THREE.MeshStandardMaterial({ color: '#4cc9ff', emissive: '#4cc9ff', emissiveIntensity: 0.8, roughness: 0.2, }); return mat; }, []); const accentDepth = 0.002; return ( {/* Main body */} {/* Front-panel darker inset (slightly recessed visually) */} {/* Power LED */} ); } /** * Plant / flower: terracotta pot with foliage on top. Rendering switches * between a few styles based on `metadata.variant`: * - `flower` → short pot with a coloured blossom sphere (uses * `metadata.flowerColor` or a warm default) * - `tall` → tall pot with an elongated green foliage cylinder capped * by a sphere (ficus / indoor tree look) * - `bush` (default) → pot with a spherical green foliage ball * No new data model is required — the variant is set at placement time * via the FurnitureDef's `defaultMetadata`. */ function PlantMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const variant = ((item.metadata?.['variant'] as string | undefined) ?? 'bush').toLowerCase(); const flowerColorRaw = item.metadata?.['flowerColor']; const flowerColor = typeof flowerColorRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(flowerColorRaw) ? flowerColorRaw : '#e05570'; const potHeight = Math.min(item.height * 0.35, 0.25); const potRadiusTop = Math.min(item.width, item.depth) / 2; const potRadiusBottom = potRadiusTop * 0.75; const foliageBottom = potHeight; const foliageHeight = item.height - potHeight; const potMaterial = useMemo(() => getFurnitureMaterial('#a85a3a', 0.8), []); const soilMaterial = useMemo(() => getFurnitureMaterial('#3a2a1a', 0.95), []); const leafMaterial = useMemo(() => getFurnitureMaterial(color, 0.85), [color]); const flowerMaterial = useMemo(() => getFurnitureMaterial(flowerColor, 0.7), [flowerColor]); return ( {/* Terracotta pot */} {/* Soil disk just below the pot rim */} {/* Foliage */} {variant === 'tall' ? ( <> {/* Elongated trunk + top sphere */} ) : variant === 'flower' ? ( <> {/* Short stem */} {/* Blossom */} ) : ( // Default: leafy bush — a single foliage sphere just above the pot )} ); } /** * Mirror: a framed reflective panel. Two variants selected via * `metadata.variant`: * * - `wall` (default): thin wall-mounted panel. Only the front-facing * mirror and the frame are drawn — no stand. The item's `depth` * should be small (~3cm) and it's placed flush against a wall, so * elevation controls where on the wall it hangs. * * - `floor`: free-standing full-length mirror with an A-frame stand * behind it. A tilt is NOT applied — the mirror sits upright against * the stand, and the stand legs splay backward into the `depth` * dimension so the mesh remains within the item's bounding box. * Elevation should be 0 (on the floor). * * The frame color is a wood tone by default; the mirror surface itself * uses a cool pale blue/grey material with low roughness so it reads as * glass without requiring real reflections. */ function MirrorMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const variantRaw = item.metadata?.['variant']; const variant = typeof variantRaw === 'string' ? variantRaw.toLowerCase() : 'wall'; const isFloor = variant === 'floor'; const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]); const mirrorMaterial = useMemo(() => getFurnitureMaterial('#c8dce3', 0.05), []); // Mirror sits at the back of the bounding box so the frame protrudes // forward and the stand (for floor variant) can live in front of it. const frameThickness = 0.03; const panelThickness = 0.015; const frameInset = 0.025; // frame width around the mirror edge // The glass occupies most of the height/width, with the frame wrapping it. const glassWidth = item.width - frameInset * 2; const glassHeight = item.height - frameInset * 2; // Wall variant: mirror sits at the back face of the bounding box so it // reads as flush against a wall. Floor variant: the mirror is pushed // slightly forward to leave room for the stand legs behind it. const mirrorZ = isFloor ? -item.depth / 2 + frameThickness + 0.005 : -item.depth / 2 + frameThickness + panelThickness / 2; return ( {/* Frame — a rectangular ring around the mirror. Built from four rail meshes so the centre stays open for the glass. */} {/* Top rail */} {/* Bottom rail */} {/* Left rail */} {/* Right rail */} {/* Glass */} {/* Floor stand — A-frame behind the mirror. Two splayed legs + a crossbar at the top that the mirror rests against. Only rendered for the floor variant. */} {isFloor && ( <> {(() => { const legThickness = 0.025; // Legs go from the back-bottom (at mirrorZ - 0.01) splayed out // to near the front of the bounding box at ground level. const legTopY = item.height * 0.75; const legTopZ = mirrorZ - 0.005; const legBottomZ = item.depth / 2 - legThickness; const legLength = Math.hypot(legTopY, legBottomZ - legTopZ); const angle = Math.atan2(legBottomZ - legTopZ, -legTopY); // tilt around X return ( <> {/* Back crossbar connecting the tops of the two legs — a short horizontal rail the mirror rests against. */} {/* Two splayed legs */} {[-1, 1].map((side) => ( ))} ); })()} )} ); } /** * Digital piano: a slim keyboard chassis with an optional X-frame stand. * * The stand is controlled via `metadata.hasStand`: when true (default), * the chassis sits elevated on a pair of X-shaped legs and the total * `item.height` includes the stand. When false, the chassis rests on * whatever surface the piano is placed on (i.e., starts at `y=0` in the * item's local frame) and the full height is the chassis height. * * The keyboard's top surface shows a row of alternating white/black * keys so the instrument reads as a piano from any angle. */ function DigitalPianoMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const hasStandRaw = item.metadata?.['hasStand']; const hasStand = typeof hasStandRaw === 'boolean' ? hasStandRaw : true; const chassisMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]); const standMaterial = useMemo(() => getFurnitureMaterial('#15151a', 0.5), []); const whiteKeyMaterial = useMemo(() => getFurnitureMaterial('#f4f0e8', 0.35), []); const blackKeyMaterial = useMemo(() => getFurnitureMaterial('#0a0a0e', 0.2), []); // Split the total height between chassis and stand. Chassis is ~15cm // regardless; the rest is stand (or zero if no stand). const chassisHeight = 0.15; const standHeight = hasStand ? Math.max(0, item.height - chassisHeight) : 0; const chassisBottom = standHeight; const chassisCenterY = chassisBottom + chassisHeight / 2; // Keys sit on top of the chassis, inset slightly from the edges. const keyAreaWidth = item.width * 0.95; const keyAreaDepth = item.depth * 0.6; const keyAreaX0 = -keyAreaWidth / 2; const keyAreaZ = item.depth * 0.05; // shifted toward the front (player-facing) const whiteKeyTopY = chassisBottom + chassisHeight + 0.005; const whiteKeyHeight = 0.015; // Number of white keys scales with width; a typical 88-key digital // piano is ~1.27m wide with 52 white keys, so we target roughly one // white key every 2.4cm. const whiteKeyCount = Math.max(12, Math.round(item.width / 0.024)); const whiteKeyWidth = keyAreaWidth / whiteKeyCount; // Black keys appear on 5 out of every 7 white key positions (after // white indices 0, 1, 3, 4, 5). We draw one shorter + slightly // raised on top of the white key row. const blackKeyPositions: number[] = []; for (let i = 0; i < whiteKeyCount - 1; i++) { const mod = i % 7; if (mod !== 2 && mod !== 6) { blackKeyPositions.push(i); } } return ( {/* Stand (X-frame legs + horizontal crossbar). Only when hasStand. */} {hasStand && standHeight > 0.05 && ( <> {/* Two splayed legs at the left and right of the chassis, forming an X when viewed from the front. Simplified to two diagonal bars on each side rather than a true X, which is hard to render with a single box. */} {[-1, 1].map((side) => ( ))} {/* Horizontal crossbar near the bottom for stability */} )} {/* Chassis (the piano body) */} {/* White key row */} {Array.from({ length: whiteKeyCount }).map((_, i) => { const localX = keyAreaX0 + (i + 0.5) * whiteKeyWidth; return ( ); })} {/* Black keys sit on the gaps between adjacent white keys */} {blackKeyPositions.map((i) => { const localX = keyAreaX0 + (i + 1) * whiteKeyWidth; return ( ); })} ); } /** * Audio speaker with two variants selected via `metadata.variant`: * * - `shelf` (default): compact bookshelf speaker — a squat rectangular * cabinet with one tweeter dome and one woofer cone on the front face. * Typically placed on a shelf, desk, or stand. Height ≤ 0.5 m. * * - `floor`: full-range tower / floor-standing speaker — a tall, slim * cabinet with a tweeter, a midrange, and a woofer stacked on the * front. Sits directly on the floor. Height usually > 0.8 m. * * The cabinet is a single box, the drivers are flat discs pressed * against the front face. The driver layout is derived from the height * so both variants read correctly as speakers regardless of exact * preset dimensions. */ function SpeakerMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const variantRaw = item.metadata?.['variant']; const variant = typeof variantRaw === 'string' ? variantRaw.toLowerCase() : 'shelf'; const isFloor = variant === 'floor'; const cabinetMaterial = useMemo(() => getFurnitureMaterial(color, 0.55), [color]); // Subtle accent for driver cones (warm off-black so they read as // distinct from the cabinet even on a dark cabinet). const driverMaterial = useMemo(() => getFurnitureMaterial('#2a2520', 0.3), []); const tweeterMaterial = useMemo(() => getFurnitureMaterial('#3a3430', 0.15), []); // Cabinet const cabinetHeight = item.height; const frontZ = item.depth / 2 + 0.001; // Driver sizing — scale with cabinet width so drivers always fit // comfortably within the front face and look proportional. const maxDriverRadius = item.width * 0.4; const tweeterRadius = Math.min(0.025, maxDriverRadius * 0.3); const midRadius = Math.min(0.05, maxDriverRadius * 0.55); const wooferRadius = Math.min(0.09, maxDriverRadius * 0.85); const driverThickness = 0.006; // Vertical placement: tweeter near the top, woofer near the bottom, // midrange between (floor variant only). All positions are in the // cabinet's local frame where y=0 is the floor. const topY = cabinetHeight - tweeterRadius - 0.03; const bottomY = wooferRadius + 0.04; const midY = (topY + bottomY) / 2; return ( {/* Cabinet body */} {/* Tweeter (top) — small recessed disc */} {/* Midrange — only on floor towers, sits between tweeter and woofer */} {isFloor && ( )} {/* Woofer — larger driver near the bottom */} {/* Subtle phase plug in the centre of the woofer (tiny dome) */} ); } /** * Office chair: wheeled 5-star base → gas-lift post → seat pan → tall * backrest → two short armrests. Proportions are driven by `item.height` * (total top-of-backrest) and `item.width` (seat pan width), so the * same mesh looks correct across preset sizes from low task chair to * tall executive chair. The seat height is derived as a fraction of * total so the backrest is always recognisably taller than the seat. */ function OfficeChairMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { const uphMaterial = useMemo(() => getFurnitureMaterial(color, 0.75), [color]); const frameMaterial = useMemo(() => getFurnitureMaterial('#1a1a1e', 0.4), []); const casterMaterial = useMemo(() => getFurnitureMaterial('#141418', 0.3), []); const seatHeight = item.height * 0.42; const seatPanThickness = 0.05; const backrestBottom = seatHeight + seatPanThickness; const backrestHeight = item.height - backrestBottom; const seatWidth = item.width; const seatDepth = item.depth * 0.85; // 5-star base. The arms splay out radially from a central hub; each // arm carries a caster at its outer end. We render the arms as thin // boxes oriented toward the outer caster position. const baseRadius = Math.min(item.width, item.depth) * 0.55; const hubRadius = 0.035; const armThickness = 0.02; const casterRadius = 0.022; const armCount = 5; // Gas-lift cylinder from hub up to just under the seat const postRadius = 0.025; const postHeight = seatHeight - 0.05; // leave room for the hub return ( {/* 5-star base — central hub + 5 splayed arms + casters */} {Array.from({ length: armCount }).map((_, i) => { const angle = (i / armCount) * Math.PI * 2; const midR = baseRadius * 0.5; const cx = Math.cos(angle) * midR; const cz = Math.sin(angle) * midR; return ( {/* Arm — rotated around Y so its long axis points outward */} {/* Caster (sphere) at the outer end of the arm */} ); })} {/* Gas-lift post */} {/* Seat pan */} {/* Backrest — taller than it is wide, slightly set back from the seat */} {/* Armrests — two short horizontal pads at ~elbow height */} {([-1, 1] as const).map((side) => { const armY = seatHeight + seatPanThickness + 0.18; const armX = side * (seatWidth / 2 + 0.015); return ( {/* Vertical support from seat level up to the pad */} {/* Horizontal pad */} ); })} ); } function getFurnitureComponent(type: FurnitureType) { switch (type) { case 'BED': return BedMesh; case 'CRIB': return CribMesh; case 'DESK': return DeskMesh; case 'WARDROBE': return WardrobeMesh; case 'SOFA': return SofaMesh; case 'TABLE': return TableMesh; case 'CHAIR': return ChairMesh; case 'OFFICE_CHAIR': return OfficeChairMesh; case 'BOOKCASE': return BookcaseMesh; case 'TV': return TvMesh; case 'PC_TOWER': return PcTowerMesh; case 'AC_UNIT': return SimpleBoxMesh; case 'RADIATOR': return RadiatorMesh; case 'WALL_COLLAGE': return WallCollageMesh; case 'CURTAIN': return CurtainMesh; case 'DRESSING_TABLE': return DressingTableMesh; case 'PLANT': return PlantMesh; case 'MIRROR': return MirrorMesh; case 'DIGITAL_PIANO': return DigitalPianoMesh; case 'SPEAKER': return SpeakerMesh; case 'SHELF': case 'NIGHTSTAND': case 'DRESSER': case 'OTHER': default: return SimpleBoxMesh; } } export function FurnitureMesh({ item, isSelected, onSelect, globalOpacity = 1 }: FurnitureMeshProps) { const Component = useMemo(() => getFurnitureComponent(item.type), [item.type]); const color = isSelected ? SELECTED_COLOR : FURNITURE_COLORS[item.type]; // (item.x, item.y) is the anchored point on the rotated visual. Use the // rotation-aware helper so "left" tracks the visual left edge of the // rotated box. Reduces to (0, 0) for the default middle/middle anchor. const offset = rotatedAnchorOffsetToCenter( item.positionAnchor, item.width, item.depth, item.rotation, ); const centerX = item.x + offset.dx; const centerY = item.y + offset.dy; // The inner mesh components draw from shared, module-level materials so // that identical furniture (e.g. three radiators of the same color) can // reuse GPU uploads. That means mutating `material.opacity` on one item // would ghost every other item that references the same singleton. // // To give each item its own opacity we clone the shared materials at // mount time (and whenever the inner component re-renders with new // materials — e.g. when width/height/type/color change), keep the clones // in a ref so we can dispose them on unmount, and mutate the clones' // opacity whenever the effective opacity changes. The clones are owned // by THIS FurnitureMesh instance exclusively. // // Effective opacity = per-item opacity × global furniture opacity. // Using the product means per-item and global both compose correctly // and the global slider no longer needs to mutate shared singletons. const groupRef = useRef(null); const clonedMaterialsRef = useRef([]); const effectiveOpacity = (item.opacity ?? 1) * globalOpacity; const shouldCastShadow = effectiveOpacity > SHADOW_OPACITY_THRESHOLD; // Clone materials after the inner component has mounted / re-rendered. // Re-running on dimension / type / color / rotation / variant changes is // important because the inner component may emit fresh mesh instances // that reference the shared pool again. Each run disposes the previous // set of clones before making new ones. useEffect(() => { const group = groupRef.current; if (!group) return; // Dispose any clones from a previous render before creating new ones. for (const m of clonedMaterialsRef.current) { m.dispose(); } const cloned: THREE.Material[] = []; group.traverse((obj) => { if (!(obj as THREE.Mesh).isMesh) return; const mesh = obj as THREE.Mesh; if (!mesh.material) return; if (Array.isArray(mesh.material)) { const replacements = mesh.material.map((m) => { const c = m.clone(); cloned.push(c); return c; }); mesh.material = replacements; } else { const c = mesh.material.clone(); cloned.push(c); mesh.material = c; } mesh.castShadow = shouldCastShadow; }); clonedMaterialsRef.current = cloned; return () => { for (const m of cloned) m.dispose(); }; // `shouldCastShadow` is intentionally included so the traversal also // updates castShadow on newly-cloned meshes after an opacity change // that crosses the threshold. }, [ shouldCastShadow, item.type, item.width, item.depth, item.height, color, // Re-clone when curtain/plant/mirror variant changes, because the // inner component may swap between branches that emit different // meshes (different material pools). item.metadata, ]); // Apply the effective opacity to the clones whenever it changes. // This runs both after the cloning effect above (via the `cloned` // array in the ref) and on every subsequent opacity change, without // re-cloning materials. useEffect(() => { for (const mat of clonedMaterialsRef.current) { // MeshStandardMaterial is the only material type the inner // components create. Cast lets us mutate opacity fields uniformly. const m = mat as THREE.MeshStandardMaterial; m.transparent = effectiveOpacity < 1; m.opacity = effectiveOpacity; m.depthWrite = effectiveOpacity >= 1; m.needsUpdate = true; } }, [effectiveOpacity]); return ( { e.stopPropagation(); onSelect(item.id); } : undefined} > ); }