Files
house-plan-maker/apps/client/src/components/editor/three/FurnitureMesh.tsx
T
alexei.dolgolyov 8c61cd182e feat(furniture): add scratching post with simple, tree, condo, wall variants
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.
2026-04-16 14:59:04 +03:00

1914 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (23 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: 23 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>
);
}