8c61cd182e
New SCRATCHING_POST type under a new "pets" category with 8 presets covering single posts, multi-tier cat trees, enclosed condos, and wall-mounted scratcher pads. Includes 3D meshes, i18n labels (en/ru), and palette integration.
1914 lines
76 KiB
TypeScript
1914 lines
76 KiB
TypeScript
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, getAcUnitStyle } 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<FurnitureType, string> = {
|
||
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',
|
||
SCRATCHING_POST: '#c8a96e',
|
||
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 });
|
||
|
||
/**
|
||
* Shift a hex color lighter (positive amount) or darker (negative).
|
||
* `amount` is in the range [-1, 1]; 0 returns the color unchanged.
|
||
* Non-hex inputs fall through so callers don't need to pre-validate.
|
||
*/
|
||
function shadeHexColor(hex: string, amount: number): string {
|
||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||
if (!match) return hex;
|
||
const n = parseInt(match[1], 16);
|
||
const shift = (channel: number): number => {
|
||
const target = amount >= 0 ? 255 : 0;
|
||
const v = Math.round(channel + (target - channel) * Math.abs(amount));
|
||
return Math.max(0, Math.min(255, v));
|
||
};
|
||
const r = shift((n >> 16) & 0xff);
|
||
const g = shift((n >> 8) & 0xff);
|
||
const b = shift(n & 0xff);
|
||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||
}
|
||
|
||
const furnitureMaterials: Record<string, THREE.MeshStandardMaterial> = {};
|
||
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 (
|
||
<group>
|
||
{/* Frame */}
|
||
<mesh position={[0, frameHeight / 2, 0]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[item.width, frameHeight, item.depth]} />
|
||
</mesh>
|
||
{/* Mattress */}
|
||
<mesh position={[0, frameHeight + mattressHeight / 2, 0]} castShadow material={mattressMaterial}>
|
||
<boxGeometry args={[item.width * 0.95, mattressHeight, item.depth * 0.95]} />
|
||
</mesh>
|
||
{/* Headboard */}
|
||
<mesh position={[0, headboardHeight / 2, -item.depth / 2 + 0.02]} castShadow material={headboardMaterial}>
|
||
<boxGeometry args={[item.width, headboardHeight, 0.04]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<group>
|
||
{/* Top */}
|
||
<mesh position={[0, item.height - topThickness / 2, 0]} castShadow material={topMaterial}>
|
||
<boxGeometry args={[item.width, topThickness, item.depth]} />
|
||
</mesh>
|
||
{/* 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) => (
|
||
<mesh key={i} position={[x, legHeight / 2, z]} castShadow material={legMaterial} scale={[1, legHeight, 1]}>
|
||
<primitive object={legGeometry} attach="geometry" />
|
||
</mesh>
|
||
))}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<group>
|
||
<mesh position={[0, item.height / 2, 0]} castShadow material={bodyMaterial}>
|
||
<boxGeometry args={[item.width, item.height, item.depth]} />
|
||
</mesh>
|
||
{/* Door divider line */}
|
||
<mesh
|
||
position={[0, item.height / 2, item.depth / 2 + 0.001]}
|
||
castShadow
|
||
material={legMaterialSmooth}
|
||
scale={[1, item.height * 0.9, 1]}
|
||
>
|
||
<primitive object={dividerLineGeometry} attach="geometry" />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<group>
|
||
{/* Seat */}
|
||
<mesh position={[0, seatHeight / 2, backDepth / 2]} castShadow material={sofaMaterial}>
|
||
<boxGeometry args={[item.width, seatHeight, item.depth - backDepth]} />
|
||
</mesh>
|
||
{/* Backrest */}
|
||
<mesh position={[0, backHeight / 2, -item.depth / 2 + backDepth / 2]} castShadow material={sofaMaterial}>
|
||
<boxGeometry args={[item.width, backHeight, backDepth]} />
|
||
</mesh>
|
||
{/* Armrests */}
|
||
{[-1, 1].map((side) => (
|
||
<mesh
|
||
key={side}
|
||
position={[side * (item.width / 2 - 0.04), seatHeight + 0.1, 0]}
|
||
castShadow
|
||
material={sofaMaterial}
|
||
>
|
||
<boxGeometry args={[0.08, 0.2, item.depth * 0.8]} />
|
||
</mesh>
|
||
))}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<group>
|
||
<mesh position={[0, item.height - topThickness / 2, 0]} castShadow material={topMaterial}>
|
||
<boxGeometry args={[item.width, topThickness, item.depth]} />
|
||
</mesh>
|
||
{[
|
||
[-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) => (
|
||
<mesh key={i} position={[x, legHeight / 2, z]} castShadow material={legMaterial} scale={[1, legHeight, 1]}>
|
||
<primitive object={legGeometry} attach="geometry" />
|
||
</mesh>
|
||
))}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<group>
|
||
{/* Seat */}
|
||
<mesh position={[0, seatHeight - seatThickness / 2, 0]} castShadow material={chairMaterial}>
|
||
<boxGeometry args={[item.width, seatThickness, item.depth]} />
|
||
</mesh>
|
||
{/* Backrest */}
|
||
<mesh position={[0, (seatHeight + item.height) / 2, -item.depth / 2 + 0.015]} castShadow material={chairMaterial}>
|
||
<boxGeometry args={[item.width * 0.9, item.height - seatHeight, 0.03]} />
|
||
</mesh>
|
||
{/* 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) => (
|
||
<mesh key={i} position={[x, legHeight / 2, z]} castShadow material={legMaterial} scale={[1, legHeight, 1]}>
|
||
<primitive object={legGeometry} attach="geometry" />
|
||
</mesh>
|
||
))}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/** Shelf / Bookcase / 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 (
|
||
<mesh position={[0, item.height / 2, 0]} castShadow material={material}>
|
||
<boxGeometry args={[item.width, item.height, item.depth]} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
/** Nightstand: box with optional legs */
|
||
function NightstandMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
||
const hasLegs = (item.metadata?.['hasLegs'] as boolean | undefined) ?? false;
|
||
const legHeight = hasLegs ? (item.metadata?.['legHeight'] as number | undefined) ?? 0.12 : 0;
|
||
const bodyHeight = item.height - legHeight;
|
||
const material = useSurfaceMaterial(item, color, 0.6, item.width, item.depth);
|
||
const legThickness = 0.025;
|
||
const insetX = item.width / 2 - legThickness / 2 - 0.01;
|
||
const insetZ = item.depth / 2 - legThickness / 2 - 0.01;
|
||
|
||
return (
|
||
<group>
|
||
{/* Body */}
|
||
<mesh position={[0, legHeight + bodyHeight / 2, 0]} castShadow material={material}>
|
||
<boxGeometry args={[item.width, bodyHeight, item.depth]} />
|
||
</mesh>
|
||
{/* Legs */}
|
||
{hasLegs && legHeight > 0 && (
|
||
<>
|
||
{([
|
||
[-insetX, -insetZ],
|
||
[insetX, -insetZ],
|
||
[-insetX, insetZ],
|
||
[insetX, insetZ],
|
||
] as const).map(([x, z], i) => (
|
||
<mesh key={i} position={[x, legHeight / 2, z]} castShadow>
|
||
<boxGeometry args={[legThickness, legHeight, legThickness]} />
|
||
<meshStandardMaterial color={color} roughness={0.6} />
|
||
</mesh>
|
||
))}
|
||
</>
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* LG-style split AC indoor unit. White body with a dark air vent/louver
|
||
* at the bottom and an angled deflector flap. Dimensions follow the
|
||
* furniture item's width/height/depth.
|
||
*/
|
||
function AcUnitLgMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
||
const { width: w, height: h, depth: d } = item;
|
||
// Body proportions
|
||
const bodyH = h * 0.72;
|
||
const ventH = h * 0.28;
|
||
const flapThick = 0.005;
|
||
const flapAngle = -Math.PI / 6; // 30° open
|
||
|
||
return (
|
||
<group>
|
||
{/* Main body — white casing */}
|
||
<mesh position={[0, h - bodyH / 2, 0]} castShadow>
|
||
<boxGeometry args={[w, bodyH, d]} />
|
||
<meshStandardMaterial color={color} roughness={0.15} />
|
||
</mesh>
|
||
{/* Dark air vent recess */}
|
||
<mesh position={[0, ventH / 2, d * 0.02]} castShadow>
|
||
<boxGeometry args={[w * 0.96, ventH * 0.85, d * 0.6]} />
|
||
<meshStandardMaterial color="#1a1a1a" roughness={0.8} />
|
||
</mesh>
|
||
{/* Vent grille lines */}
|
||
{Array.from({ length: 5 }).map((_, i) => {
|
||
const yOff = ventH * 0.15 + (i / 4) * ventH * 0.7;
|
||
return (
|
||
<mesh key={i} position={[0, yOff, d * 0.35]} castShadow>
|
||
<boxGeometry args={[w * 0.90, 0.003, 0.002]} />
|
||
<meshStandardMaterial color="#333333" roughness={0.6} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
{/* Deflector flap — angled open at the bottom of the vent */}
|
||
<group position={[0, ventH * 0.08, d * 0.5]} rotation={[flapAngle, 0, 0]}>
|
||
<mesh position={[0, -ventH * 0.15, 0]} castShadow>
|
||
<boxGeometry args={[w * 0.94, ventH * 0.30, flapThick]} />
|
||
<meshStandardMaterial color="#e0e0e0" roughness={0.2} />
|
||
</mesh>
|
||
</group>
|
||
{/* LG logo indicator — small dark rectangle on upper body */}
|
||
<mesh position={[0, h * 0.6, d * 0.51]}>
|
||
<boxGeometry args={[w * 0.06, h * 0.02, 0.001]} />
|
||
<meshStandardMaterial color="#cc0033" roughness={0.3} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* AC unit dispatcher: picks style from metadata.
|
||
*/
|
||
function AcUnitMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
||
const style = getAcUnitStyle(item.metadata);
|
||
if (style === 'lg') {
|
||
return <AcUnitLgMesh item={item} color={color} />;
|
||
}
|
||
return <SimpleBoxMesh item={item} color={color} />;
|
||
}
|
||
|
||
/**
|
||
* 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 metadataCols = item.metadata?.['shelfColumns'];
|
||
const rawCols =
|
||
typeof metadataCols === 'number' && Number.isFinite(metadataCols) && metadataCols >= 1
|
||
? Math.round(metadataCols)
|
||
: 1;
|
||
const shelfColumns = Math.max(1, Math.min(12, rawCols));
|
||
|
||
const hasBackPanelRaw = item.metadata?.['hasBackPanel'];
|
||
const hasBackPanel = typeof hasBackPanelRaw === 'boolean' ? hasBackPanelRaw : true;
|
||
|
||
// Per-cell drawer flags. Keys are `${row}_${col}` with row 0 at the
|
||
// bottom. Only "true" entries are stored so stale keys for removed
|
||
// cells are harmless.
|
||
const drawerCellsRaw = item.metadata?.['drawerCells'];
|
||
const drawerCells =
|
||
drawerCellsRaw && typeof drawerCellsRaw === 'object' && !Array.isArray(drawerCellsRaw)
|
||
? (drawerCellsRaw as Record<string, unknown>)
|
||
: {};
|
||
|
||
const panelThickness = 0.02;
|
||
const material = useSurfaceMaterial(item, color, 0.6, item.width, item.height);
|
||
const handleMaterial = useMemo(() => getFurnitureMaterial('#1a1a1a', 0.35), []);
|
||
// Drawer fronts are rendered a touch darker than the body so they
|
||
// read as distinct panels without clashing with the item color.
|
||
const drawerFrontColor = useMemo(() => shadeHexColor(color, -0.12), [color]);
|
||
const drawerFrontMaterial = useMemo(
|
||
() => getFurnitureMaterial(drawerFrontColor, 0.55),
|
||
[drawerFrontColor],
|
||
);
|
||
|
||
return (
|
||
<group>
|
||
{/* Back panel (optional). When omitted the bookshelf reads as an
|
||
open unit that can be seen through from both sides. */}
|
||
{hasBackPanel && (
|
||
<mesh position={[0, item.height / 2, -item.depth / 2 + panelThickness / 2]} castShadow material={material}>
|
||
<boxGeometry args={[item.width, item.height, panelThickness]} />
|
||
</mesh>
|
||
)}
|
||
{/* Side panels */}
|
||
{[-1, 1].map((side) => (
|
||
<mesh
|
||
key={side}
|
||
position={[side * (item.width / 2 - panelThickness / 2), item.height / 2, 0]}
|
||
castShadow
|
||
material={material}
|
||
>
|
||
<boxGeometry args={[panelThickness, item.height, item.depth]} />
|
||
</mesh>
|
||
))}
|
||
{/* 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 (
|
||
<mesh key={`h${i}`} position={[0, y, 0]} castShadow material={material}>
|
||
<boxGeometry args={[item.width - panelThickness * 2, panelThickness, item.depth]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
{/* Vertical dividers. `shelfColumns` compartments produce
|
||
`shelfColumns - 1` internal vertical boards (the outer two
|
||
are the side panels already rendered above). */}
|
||
{Array.from({ length: Math.max(0, shelfColumns - 1) }).map((_, i) => {
|
||
const x = -item.width / 2 + ((i + 1) / shelfColumns) * item.width;
|
||
// Keep the divider inside the back panel + front plane so its
|
||
// faces don't coincide with the back panel's front face or jut
|
||
// past the drawer fronts.
|
||
const dividerDepth = hasBackPanel ? item.depth - panelThickness : item.depth;
|
||
const dividerZ = hasBackPanel ? panelThickness / 2 : 0;
|
||
return (
|
||
<mesh key={`v${i}`} position={[x, item.height / 2, dividerZ]} castShadow material={material}>
|
||
<boxGeometry args={[panelThickness, item.height - panelThickness * 2, dividerDepth]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
{/* Drawer fronts. For each cell flagged in `drawerCells`, draw a
|
||
face panel flush with the front of the unit. A thin recessed
|
||
handle strip distinguishes it from an empty compartment. */}
|
||
{Array.from({ length: shelfRows }).flatMap((_, r) =>
|
||
Array.from({ length: shelfColumns }).map((__, c) => {
|
||
if (drawerCells[`${r}_${c}`] !== true) return null;
|
||
const cellW = item.width / shelfColumns;
|
||
const cellH = item.height / shelfRows;
|
||
const frontW = cellW - panelThickness * 2;
|
||
const frontH = cellH - panelThickness * 2;
|
||
const cx = -item.width / 2 + (c + 0.5) * cellW;
|
||
const cy = (r + 0.5) * cellH;
|
||
const cz = item.depth / 2 - panelThickness / 2;
|
||
return (
|
||
<group key={`d${r}_${c}`}>
|
||
<mesh position={[cx, cy, cz]} castShadow material={drawerFrontMaterial}>
|
||
<boxGeometry args={[frontW, frontH, panelThickness]} />
|
||
</mesh>
|
||
{/* Handle — a dark bar stands out against wood/paint. */}
|
||
<mesh
|
||
position={[cx, cy + frontH * 0.25, cz + panelThickness / 2 + 0.01]}
|
||
castShadow
|
||
material={handleMaterial}
|
||
>
|
||
<boxGeometry args={[frontW * 0.4, frontH * 0.06, 0.02]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}),
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<group>
|
||
{/* Screen */}
|
||
<mesh position={[0, standHeight + screenHeight / 2, 0]} castShadow material={screenMaterial}>
|
||
<boxGeometry args={[item.width, screenHeight, screenThickness]} />
|
||
</mesh>
|
||
{/* Frame border */}
|
||
<mesh position={[0, standHeight + screenHeight / 2, screenThickness / 2 + 0.001]} material={frameMaterial}>
|
||
<boxGeometry args={[item.width + 0.02, screenHeight + 0.02, 0.005]} />
|
||
</mesh>
|
||
{hasStand && (
|
||
<>
|
||
{/* Stand */}
|
||
<mesh position={[0, standHeight / 2, 0]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[0.04, standHeight, item.depth]} />
|
||
</mesh>
|
||
{/* Stand base */}
|
||
<mesh position={[0, 0.005, 0]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[item.width * 0.4, 0.01, item.depth]} />
|
||
</mesh>
|
||
</>
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Frame backing panel */}
|
||
<mesh position={[0, item.height / 2, 0]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[item.width, item.height, panelDepth]} />
|
||
</mesh>
|
||
{/* 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 (
|
||
<mesh
|
||
key={`cell-${r}-${c}`}
|
||
position={[localX, localY, panelDepth / 2 + 0.001]}
|
||
material={photoMaterials[idx % photoMaterials.length]}
|
||
>
|
||
<boxGeometry args={[cellW, cellH, 0.002]} />
|
||
</mesh>
|
||
);
|
||
}),
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Back panel */}
|
||
<mesh position={[0, item.height / 2, -item.depth / 2 + panelDepth / 2]} castShadow material={bodyMaterial}>
|
||
<boxGeometry args={[item.width, item.height, panelDepth]} />
|
||
</mesh>
|
||
{/* Vertical fins */}
|
||
{Array.from({ length: finCount }).map((_, i) => {
|
||
const offsetX = -item.width / 2 + (i + 0.5) * (item.width / finCount);
|
||
return (
|
||
<mesh
|
||
key={i}
|
||
position={[offsetX, item.height / 2, 0]}
|
||
castShadow
|
||
material={bodyMaterial}
|
||
>
|
||
<boxGeometry args={[finWidth, item.height * 0.95, finDepth]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
{/* Top cap (the slotted grille typical of panel radiators) */}
|
||
<mesh position={[0, item.height - 0.005, 0]} castShadow material={bodyMaterial}>
|
||
<boxGeometry args={[item.width, 0.01, item.depth]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Horizontal rod runs the full width regardless of open state. */}
|
||
<mesh
|
||
position={[0, rodY, -item.depth / 2 + rodRadius]}
|
||
rotation={[0, 0, Math.PI / 2]}
|
||
castShadow
|
||
material={rodMaterial}
|
||
>
|
||
<cylinderGeometry args={[rodRadius, rodRadius, item.width + 0.1, 8]} />
|
||
</mesh>
|
||
{/* Rod finials (end caps) */}
|
||
{[-1, 1].map((side) => (
|
||
<mesh
|
||
key={`finial-${side}`}
|
||
position={[side * (halfWidth + 0.05), rodY, -item.depth / 2 + rodRadius]}
|
||
castShadow
|
||
material={rodMaterial}
|
||
>
|
||
<sphereGeometry args={[rodRadius * 1.8, 10, 8]} />
|
||
</mesh>
|
||
))}
|
||
{/* 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 (
|
||
<mesh
|
||
key={`pleat-L-${i}`}
|
||
position={[localX, fabricHeight / 2, zOffset]}
|
||
castShadow
|
||
material={fabricMaterial}
|
||
>
|
||
<boxGeometry args={[leftPleatWidth * 0.95, fabricHeight, panelDepth]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
{/* 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 (
|
||
<mesh
|
||
key={`pleat-R-${i}`}
|
||
position={[localX, fabricHeight / 2, zOffset]}
|
||
castShadow
|
||
material={fabricMaterial}
|
||
>
|
||
<boxGeometry args={[rightPleatWidth * 0.95, fabricHeight, panelDepth]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Bottom rail ring (sits just above the mattress top) */}
|
||
<mesh
|
||
position={[0, mattressLift + railThick / 2, -item.depth / 2 + railThick / 2]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[item.width, railThick, railThick]} />
|
||
</mesh>
|
||
<mesh
|
||
position={[0, mattressLift + railThick / 2, item.depth / 2 - railThick / 2]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[item.width, railThick, railThick]} />
|
||
</mesh>
|
||
<mesh
|
||
position={[-item.width / 2 + railThick / 2, mattressLift + railThick / 2, 0]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[railThick, railThick, item.depth]} />
|
||
</mesh>
|
||
<mesh
|
||
position={[item.width / 2 - railThick / 2, mattressLift + railThick / 2, 0]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[railThick, railThick, item.depth]} />
|
||
</mesh>
|
||
{/* Top rail ring */}
|
||
<mesh position={[0, item.height - railThick / 2, -item.depth / 2 + railThick / 2]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[item.width, railThick, railThick]} />
|
||
</mesh>
|
||
<mesh position={[0, item.height - railThick / 2, item.depth / 2 - railThick / 2]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[item.width, railThick, railThick]} />
|
||
</mesh>
|
||
<mesh position={[-item.width / 2 + railThick / 2, item.height - railThick / 2, 0]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[railThick, railThick, item.depth]} />
|
||
</mesh>
|
||
<mesh position={[item.width / 2 - railThick / 2, item.height - railThick / 2, 0]} castShadow material={frameMaterial}>
|
||
<boxGeometry args={[railThick, railThick, item.depth]} />
|
||
</mesh>
|
||
{/* Corner posts */}
|
||
{cornerPosts.map(({ sx, sz }) => (
|
||
<mesh
|
||
key={`post-${sx}-${sz}`}
|
||
position={[sx * (item.width / 2 - railThick / 2), item.height / 2, sz * (item.depth / 2 - railThick / 2)]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[railThick, item.height, railThick]} />
|
||
</mesh>
|
||
))}
|
||
{/* 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 (
|
||
<mesh
|
||
key={`slat-lr-${sz}-${i}`}
|
||
position={[localX, mattressLift + railThick + slatHeight / 2, sz * (item.depth / 2 - railThick / 2)]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<cylinderGeometry args={[slatRadius, slatRadius, slatHeight, 6]} />
|
||
</mesh>
|
||
);
|
||
}),
|
||
)}
|
||
{/* 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 (
|
||
<mesh
|
||
key={`slat-fb-${sx}-${i}`}
|
||
position={[sx * (item.width / 2 - railThick / 2), mattressLift + railThick + slatHeight / 2, localZ]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<cylinderGeometry args={[slatRadius, slatRadius, slatHeight, 6]} />
|
||
</mesh>
|
||
);
|
||
}),
|
||
)}
|
||
{/* Mattress */}
|
||
<mesh position={[0, mattressLift - mattressThick / 2, 0]} castShadow material={mattressMaterial}>
|
||
<boxGeometry args={[item.width - railThick * 2 - 0.01, mattressThick, item.depth - railThick * 2 - 0.01]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Desk body — a closed box representing drawers */}
|
||
<mesh position={[0, deskHeight / 2, 0]} castShadow material={bodyMaterial}>
|
||
<boxGeometry args={[item.width, deskHeight, item.depth]} />
|
||
</mesh>
|
||
{/* Drawer divider lines on the front face */}
|
||
<mesh
|
||
position={[0, deskHeight * 0.5, item.depth / 2 + 0.001]}
|
||
material={mirrorFrameMaterial}
|
||
>
|
||
<boxGeometry args={[item.width * 0.9, 0.005, 0.002]} />
|
||
</mesh>
|
||
{/* Mirror frame (slightly larger than the mirror itself) */}
|
||
<mesh
|
||
position={[0, deskHeight + mirrorHeight / 2, -item.depth / 2 + frameThickness / 2]}
|
||
castShadow
|
||
material={mirrorFrameMaterial}
|
||
>
|
||
<boxGeometry args={[mirrorWidth + frameThickness * 2, mirrorHeight, frameThickness]} />
|
||
</mesh>
|
||
{/* Mirror surface */}
|
||
<mesh
|
||
position={[0, deskHeight + mirrorHeight / 2, -item.depth / 2 + frameThickness + mirrorThickness / 2]}
|
||
material={mirrorMaterial}
|
||
>
|
||
<boxGeometry args={[mirrorWidth, mirrorHeight - frameThickness, mirrorThickness]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Main body */}
|
||
<mesh position={[0, item.height / 2, 0]} castShadow material={bodyMaterial}>
|
||
<boxGeometry args={[item.width, item.height, item.depth]} />
|
||
</mesh>
|
||
{/* Front-panel darker inset (slightly recessed visually) */}
|
||
<mesh position={[0, item.height / 2, item.depth / 2 + accentDepth / 2]} material={accentMaterial}>
|
||
<boxGeometry args={[item.width * 0.85, item.height * 0.92, accentDepth]} />
|
||
</mesh>
|
||
{/* Power LED */}
|
||
<mesh
|
||
position={[item.width * 0.3, item.height * 0.88, item.depth / 2 + accentDepth + 0.0005]}
|
||
material={ledMaterial}
|
||
>
|
||
<boxGeometry args={[0.012, 0.004, 0.001]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Terracotta pot */}
|
||
<mesh position={[0, potHeight / 2, 0]} castShadow material={potMaterial}>
|
||
<cylinderGeometry args={[potRadiusTop, potRadiusBottom, potHeight, 16]} />
|
||
</mesh>
|
||
{/* Soil disk just below the pot rim */}
|
||
<mesh position={[0, potHeight - 0.005, 0]} material={soilMaterial}>
|
||
<cylinderGeometry args={[potRadiusTop * 0.9, potRadiusTop * 0.9, 0.01, 16]} />
|
||
</mesh>
|
||
{/* Foliage */}
|
||
{variant === 'tall' ? (
|
||
<>
|
||
{/* Elongated trunk + top sphere */}
|
||
<mesh
|
||
position={[0, foliageBottom + foliageHeight * 0.5, 0]}
|
||
castShadow
|
||
material={leafMaterial}
|
||
>
|
||
<cylinderGeometry args={[potRadiusTop * 0.55, potRadiusTop * 0.7, foliageHeight, 14]} />
|
||
</mesh>
|
||
<mesh
|
||
position={[0, foliageBottom + foliageHeight, 0]}
|
||
castShadow
|
||
material={leafMaterial}
|
||
>
|
||
<sphereGeometry args={[potRadiusTop * 0.9, 16, 12]} />
|
||
</mesh>
|
||
</>
|
||
) : variant === 'flower' ? (
|
||
<>
|
||
{/* Short stem */}
|
||
<mesh
|
||
position={[0, foliageBottom + foliageHeight * 0.3, 0]}
|
||
castShadow
|
||
material={leafMaterial}
|
||
>
|
||
<cylinderGeometry args={[potRadiusTop * 0.25, potRadiusTop * 0.35, foliageHeight * 0.6, 12]} />
|
||
</mesh>
|
||
{/* Blossom */}
|
||
<mesh
|
||
position={[0, foliageBottom + foliageHeight * 0.75, 0]}
|
||
castShadow
|
||
material={flowerMaterial}
|
||
>
|
||
<sphereGeometry args={[Math.min(potRadiusTop, foliageHeight * 0.35), 16, 12]} />
|
||
</mesh>
|
||
</>
|
||
) : (
|
||
// Default: leafy bush — a single foliage sphere just above the pot
|
||
<mesh
|
||
position={[0, foliageBottom + foliageHeight * 0.55, 0]}
|
||
castShadow
|
||
material={leafMaterial}
|
||
>
|
||
<sphereGeometry args={[Math.min(potRadiusTop * 1.2, foliageHeight * 0.55), 16, 12]} />
|
||
</mesh>
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* Frame — a rectangular ring around the mirror. Built from four
|
||
rail meshes so the centre stays open for the glass. */}
|
||
{/* Top rail */}
|
||
<mesh
|
||
position={[0, item.height - frameInset / 2, mirrorZ - panelThickness / 2]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[item.width, frameInset, frameThickness]} />
|
||
</mesh>
|
||
{/* Bottom rail */}
|
||
<mesh
|
||
position={[0, frameInset / 2, mirrorZ - panelThickness / 2]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[item.width, frameInset, frameThickness]} />
|
||
</mesh>
|
||
{/* Left rail */}
|
||
<mesh
|
||
position={[-item.width / 2 + frameInset / 2, item.height / 2, mirrorZ - panelThickness / 2]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[frameInset, item.height - frameInset * 2, frameThickness]} />
|
||
</mesh>
|
||
{/* Right rail */}
|
||
<mesh
|
||
position={[item.width / 2 - frameInset / 2, item.height / 2, mirrorZ - panelThickness / 2]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[frameInset, item.height - frameInset * 2, frameThickness]} />
|
||
</mesh>
|
||
{/* Glass */}
|
||
<mesh position={[0, item.height / 2, mirrorZ]} material={mirrorMaterial}>
|
||
<boxGeometry args={[glassWidth, glassHeight, panelThickness]} />
|
||
</mesh>
|
||
|
||
{/* 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. */}
|
||
<mesh
|
||
position={[0, legTopY, legTopZ]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[item.width * 0.3, legThickness, legThickness]} />
|
||
</mesh>
|
||
{/* Two splayed legs */}
|
||
{[-1, 1].map((side) => (
|
||
<mesh
|
||
key={`leg-${side}`}
|
||
position={[
|
||
side * item.width * 0.15,
|
||
legTopY / 2 + (legTopY * 0) / 2,
|
||
(legTopZ + legBottomZ) / 2,
|
||
]}
|
||
rotation={[angle + Math.PI, 0, 0]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[legThickness, legLength, legThickness]} />
|
||
</mesh>
|
||
))}
|
||
</>
|
||
);
|
||
})()}
|
||
</>
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* 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) => (
|
||
<mesh
|
||
key={`leg-${side}`}
|
||
position={[side * (item.width / 2 - 0.1), standHeight / 2, 0]}
|
||
castShadow
|
||
material={standMaterial}
|
||
>
|
||
<boxGeometry args={[0.04, standHeight, item.depth * 0.6]} />
|
||
</mesh>
|
||
))}
|
||
{/* Horizontal crossbar near the bottom for stability */}
|
||
<mesh
|
||
position={[0, 0.05, 0]}
|
||
castShadow
|
||
material={standMaterial}
|
||
>
|
||
<boxGeometry args={[item.width - 0.25, 0.025, 0.025]} />
|
||
</mesh>
|
||
</>
|
||
)}
|
||
{/* Chassis (the piano body) */}
|
||
<mesh position={[0, chassisCenterY, 0]} castShadow material={chassisMaterial}>
|
||
<boxGeometry args={[item.width, chassisHeight, item.depth]} />
|
||
</mesh>
|
||
{/* White key row */}
|
||
{Array.from({ length: whiteKeyCount }).map((_, i) => {
|
||
const localX = keyAreaX0 + (i + 0.5) * whiteKeyWidth;
|
||
return (
|
||
<mesh
|
||
key={`wk-${i}`}
|
||
position={[localX, whiteKeyTopY + whiteKeyHeight / 2, keyAreaZ]}
|
||
material={whiteKeyMaterial}
|
||
>
|
||
<boxGeometry args={[whiteKeyWidth * 0.92, whiteKeyHeight, keyAreaDepth]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
{/* Black keys sit on the gaps between adjacent white keys */}
|
||
{blackKeyPositions.map((i) => {
|
||
const localX = keyAreaX0 + (i + 1) * whiteKeyWidth;
|
||
return (
|
||
<mesh
|
||
key={`bk-${i}`}
|
||
position={[localX, whiteKeyTopY + whiteKeyHeight + 0.005, keyAreaZ - keyAreaDepth * 0.2]}
|
||
material={blackKeyMaterial}
|
||
>
|
||
<boxGeometry args={[whiteKeyWidth * 0.55, 0.012, keyAreaDepth * 0.55]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* TV soundbar: long, thin horizontal cabinet that sits below or above a
|
||
* TV. Rendered as a rounded-edge box with a row of small driver cones
|
||
* across the front face and a muted LED strip along the bottom edge so
|
||
* it reads as an electronics item rather than a plain block.
|
||
*/
|
||
function SoundbarMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
||
const cabinetMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
|
||
const grilleMaterial = useMemo(() => getFurnitureMaterial('#1a1a1a', 0.8), []);
|
||
const driverMaterial = useMemo(() => getFurnitureMaterial('#2a2520', 0.35), []);
|
||
const ledMaterial = useMemo(() => getFurnitureMaterial('#3a6da8', 0.2), []);
|
||
|
||
const frontZ = item.depth / 2 + 0.001;
|
||
// Driver cone count scales with width so a wide bar reads denser.
|
||
const driverCount = Math.max(3, Math.min(9, Math.round(item.width / 0.15)));
|
||
const driverRadius = Math.min(item.height * 0.3, (item.width / driverCount) * 0.35);
|
||
const cy = item.height / 2;
|
||
|
||
return (
|
||
<group>
|
||
{/* Cabinet */}
|
||
<mesh position={[0, cy, 0]} castShadow material={cabinetMaterial}>
|
||
<boxGeometry args={[item.width, item.height, item.depth]} />
|
||
</mesh>
|
||
{/* Front grille — thin dark strip covering most of the front face */}
|
||
<mesh position={[0, cy, frontZ]} castShadow material={grilleMaterial}>
|
||
<boxGeometry args={[item.width * 0.96, item.height * 0.75, 0.004]} />
|
||
</mesh>
|
||
{/* Driver cones evenly spaced along the grille */}
|
||
{Array.from({ length: driverCount }).map((_, i) => {
|
||
const x = -item.width / 2 + ((i + 0.5) / driverCount) * item.width;
|
||
return (
|
||
<mesh
|
||
key={i}
|
||
position={[x, cy, frontZ + 0.003]}
|
||
rotation={[Math.PI / 2, 0, 0]}
|
||
castShadow
|
||
material={driverMaterial}
|
||
>
|
||
<cylinderGeometry args={[driverRadius, driverRadius, 0.004, 14]} />
|
||
</mesh>
|
||
);
|
||
})}
|
||
{/* LED accent strip along the bottom edge */}
|
||
<mesh position={[0, item.height * 0.08, frontZ + 0.002]} material={ledMaterial}>
|
||
<boxGeometry args={[item.width * 0.3, item.height * 0.06, 0.003]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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';
|
||
if (variant === 'soundbar') {
|
||
return <SoundbarMesh item={item} color={color} />;
|
||
}
|
||
return <SpeakerCabinetMesh item={item} color={color} variant={variant} />;
|
||
}
|
||
|
||
function SpeakerCabinetMesh({ item, color, variant }: { readonly item: FurnitureItem; readonly color: string; readonly variant: string }) {
|
||
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 (
|
||
<group>
|
||
{/* Cabinet body */}
|
||
<mesh position={[0, cabinetHeight / 2, 0]} castShadow material={cabinetMaterial}>
|
||
<boxGeometry args={[item.width, cabinetHeight, item.depth]} />
|
||
</mesh>
|
||
{/* Tweeter (top) — small recessed disc */}
|
||
<mesh
|
||
position={[0, topY, frontZ]}
|
||
rotation={[Math.PI / 2, 0, 0]}
|
||
castShadow
|
||
material={tweeterMaterial}
|
||
>
|
||
<cylinderGeometry args={[tweeterRadius, tweeterRadius, driverThickness, 16]} />
|
||
</mesh>
|
||
{/* Midrange — only on floor towers, sits between tweeter and woofer */}
|
||
{isFloor && (
|
||
<mesh
|
||
position={[0, midY, frontZ]}
|
||
rotation={[Math.PI / 2, 0, 0]}
|
||
castShadow
|
||
material={driverMaterial}
|
||
>
|
||
<cylinderGeometry args={[midRadius, midRadius, driverThickness, 16]} />
|
||
</mesh>
|
||
)}
|
||
{/* Woofer — larger driver near the bottom */}
|
||
<mesh
|
||
position={[0, bottomY, frontZ]}
|
||
rotation={[Math.PI / 2, 0, 0]}
|
||
castShadow
|
||
material={driverMaterial}
|
||
>
|
||
<cylinderGeometry args={[wooferRadius, wooferRadius, driverThickness, 18]} />
|
||
</mesh>
|
||
{/* Subtle phase plug in the centre of the woofer (tiny dome) */}
|
||
<mesh
|
||
position={[0, bottomY, frontZ + driverThickness / 2 + 0.002]}
|
||
castShadow
|
||
material={tweeterMaterial}
|
||
>
|
||
<sphereGeometry args={[wooferRadius * 0.2, 10, 8]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<group>
|
||
{/* 5-star base — central hub + 5 splayed arms + casters */}
|
||
<mesh position={[0, 0.03, 0]} castShadow material={frameMaterial}>
|
||
<cylinderGeometry args={[hubRadius, hubRadius * 0.8, 0.04, 12]} />
|
||
</mesh>
|
||
{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 (
|
||
<group key={`arm-${i}`}>
|
||
{/* Arm — rotated around Y so its long axis points outward */}
|
||
<mesh
|
||
position={[cx, 0.02, cz]}
|
||
rotation={[0, -angle, 0]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[baseRadius, armThickness, armThickness * 1.6]} />
|
||
</mesh>
|
||
{/* Caster (sphere) at the outer end of the arm */}
|
||
<mesh
|
||
position={[Math.cos(angle) * baseRadius, casterRadius, Math.sin(angle) * baseRadius]}
|
||
castShadow
|
||
material={casterMaterial}
|
||
>
|
||
<sphereGeometry args={[casterRadius, 12, 10]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
})}
|
||
{/* Gas-lift post */}
|
||
<mesh position={[0, 0.05 + postHeight / 2, 0]} castShadow material={frameMaterial}>
|
||
<cylinderGeometry args={[postRadius * 0.85, postRadius, postHeight, 12]} />
|
||
</mesh>
|
||
{/* Seat pan */}
|
||
<mesh position={[0, seatHeight + seatPanThickness / 2, 0]} castShadow material={uphMaterial}>
|
||
<boxGeometry args={[seatWidth, seatPanThickness, seatDepth]} />
|
||
</mesh>
|
||
{/* Backrest — taller than it is wide, slightly set back from the seat */}
|
||
<mesh
|
||
position={[0, backrestBottom + backrestHeight / 2, -seatDepth / 2 + 0.03]}
|
||
castShadow
|
||
material={uphMaterial}
|
||
>
|
||
<boxGeometry args={[seatWidth * 0.9, backrestHeight, 0.06]} />
|
||
</mesh>
|
||
{/* 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 (
|
||
<group key={`armrest-${side}`}>
|
||
{/* Vertical support from seat level up to the pad */}
|
||
<mesh
|
||
position={[armX, seatHeight + seatPanThickness + 0.09, 0]}
|
||
castShadow
|
||
material={frameMaterial}
|
||
>
|
||
<boxGeometry args={[0.02, 0.18, 0.03]} />
|
||
</mesh>
|
||
{/* Horizontal pad */}
|
||
<mesh position={[armX, armY, 0]} castShadow material={uphMaterial}>
|
||
<boxGeometry args={[0.04, 0.025, seatDepth * 0.7]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
})}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
// ── Scratching Post ──────────────────────────────────────────────
|
||
//
|
||
// Variants:
|
||
// simple → single sisal-wrapped post with a round platform on top
|
||
// tree → multi-tier cat tree (2–3 posts + round platforms at offsets)
|
||
// condo → enclosed cubby box with a platform on top and a post
|
||
// wall → flat wall-mounted scratcher pad
|
||
|
||
const SISAL_COLOR = '#c8a96e';
|
||
const PLATFORM_COLOR = '#8a7560';
|
||
const CONDO_COLOR = '#7a6a5a';
|
||
|
||
function ScratchingPostMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
||
const variant = (item.metadata as Record<string, unknown> | null)?.variant as string | undefined ?? 'simple';
|
||
const sisalMat = useMemo(() => getFurnitureMaterial(color, 0.85), [color]);
|
||
const platformMat = useMemo(() => getFurnitureMaterial(PLATFORM_COLOR, 0.7), []);
|
||
const condoMat = useMemo(() => getFurnitureMaterial(CONDO_COLOR, 0.6), []);
|
||
|
||
const w = item.width;
|
||
const d = item.depth;
|
||
const h = item.height;
|
||
const postRadius = Math.min(w, d) * 0.12;
|
||
const platformRadius = Math.min(w, d) * 0.4;
|
||
const platformThickness = 0.025;
|
||
|
||
if (variant === 'wall') {
|
||
// Flat scratcher pad
|
||
const padThickness = d > 0.01 ? d : 0.03;
|
||
return (
|
||
<group>
|
||
<mesh material={sisalMat} castShadow receiveShadow>
|
||
<boxGeometry args={[w, h, padThickness]} />
|
||
<mesh position={[0, 0, padThickness / 2 + 0.002]}>
|
||
<boxGeometry args={[w * 0.85, h * 0.85, 0.004]} />
|
||
<meshStandardMaterial color="#b89a5a" roughness={0.95} />
|
||
</mesh>
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
if (variant === 'condo') {
|
||
const cubbyH = h * 0.45;
|
||
const cubbyW = w * 0.8;
|
||
const cubbyD = d * 0.8;
|
||
const wallThick = 0.02;
|
||
const postH = h - cubbyH - platformThickness;
|
||
const openingRadius = Math.min(cubbyW, cubbyD) * 0.35;
|
||
|
||
return (
|
||
<group>
|
||
{/* Base platform */}
|
||
<mesh position={[0, platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||
</mesh>
|
||
{/* Cubby box */}
|
||
<mesh position={[0, platformThickness + cubbyH / 2, 0]} material={condoMat} castShadow receiveShadow>
|
||
<boxGeometry args={[cubbyW, cubbyH, cubbyD]} />
|
||
</mesh>
|
||
{/* Cubby opening (dark circle on front face) */}
|
||
<mesh position={[0, platformThickness + cubbyH * 0.45, cubbyD / 2 + 0.001]} rotation={[0, 0, 0]}>
|
||
<circleGeometry args={[openingRadius, 24]} />
|
||
<meshStandardMaterial color="#2a2018" roughness={0.95} />
|
||
</mesh>
|
||
{/* Post on top of cubby */}
|
||
<mesh
|
||
position={[0, platformThickness + cubbyH + postH / 2, 0]}
|
||
material={sisalMat}
|
||
castShadow
|
||
>
|
||
<cylinderGeometry args={[postRadius, postRadius, postH, 12]} />
|
||
</mesh>
|
||
{/* Top platform */}
|
||
<mesh
|
||
position={[0, h - platformThickness / 2, 0]}
|
||
material={platformMat}
|
||
castShadow
|
||
receiveShadow
|
||
>
|
||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
if (variant === 'tree') {
|
||
// Multi-tier cat tree: 2–3 posts at offsets with platforms
|
||
const tierCount = h > 1.2 ? 3 : 2;
|
||
const baseR = platformRadius * 1.1;
|
||
const offsets = [
|
||
{ x: -w * 0.18, z: -d * 0.15, ph: h * 0.4, pr: platformRadius * 0.85 },
|
||
{ x: w * 0.15, z: d * 0.12, ph: h * 0.7, pr: platformRadius * 0.9 },
|
||
{ x: -w * 0.05, z: d * 0.05, ph: h, pr: platformRadius },
|
||
].slice(0, tierCount);
|
||
|
||
return (
|
||
<group>
|
||
{/* Base */}
|
||
<mesh position={[0, platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||
<cylinderGeometry args={[baseR, baseR, platformThickness, 24]} />
|
||
</mesh>
|
||
{offsets.map((tier, i) => (
|
||
<group key={i}>
|
||
{/* Post */}
|
||
<mesh
|
||
position={[tier.x, platformThickness + (tier.ph - platformThickness) / 2, tier.z]}
|
||
material={sisalMat}
|
||
castShadow
|
||
>
|
||
<cylinderGeometry args={[postRadius, postRadius, tier.ph - platformThickness, 12]} />
|
||
</mesh>
|
||
{/* Platform */}
|
||
<mesh
|
||
position={[tier.x, tier.ph - platformThickness / 2, tier.z]}
|
||
material={platformMat}
|
||
castShadow
|
||
receiveShadow
|
||
>
|
||
<cylinderGeometry args={[tier.pr, tier.pr, platformThickness, 24]} />
|
||
</mesh>
|
||
</group>
|
||
))}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
// Default: simple — single post on a base with a platform on top
|
||
return (
|
||
<group>
|
||
{/* Base */}
|
||
<mesh position={[0, platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||
</mesh>
|
||
{/* Post */}
|
||
<mesh position={[0, h / 2, 0]} material={sisalMat} castShadow>
|
||
<cylinderGeometry args={[postRadius, postRadius, h - platformThickness * 2, 12]} />
|
||
</mesh>
|
||
{/* Top platform */}
|
||
<mesh position={[0, h - platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
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 AcUnitMesh;
|
||
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 'SCRATCHING_POST': return ScratchingPostMesh;
|
||
case 'NIGHTSTAND': return NightstandMesh;
|
||
case 'SHELF':
|
||
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<THREE.Group>(null);
|
||
const clonedMaterialsRef = useRef<THREE.Material[]>([]);
|
||
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 (
|
||
<group
|
||
ref={groupRef}
|
||
position={[centerX, item.elevationFromFloor, centerY]}
|
||
rotation={[0, -degToRad(item.rotation), 0]}
|
||
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(item.id); } : undefined}
|
||
>
|
||
<Component item={item} color={color} />
|
||
</group>
|
||
);
|
||
}
|