feat(furniture): nightstand legs, bookcase columns/drawers, AC unit LG style, soundbars, wood-cube sconce

- NIGHTSTAND: optional legs with adjustable height (capped at 60% of total height)
- BOOKCASE: shelfColumns with vertical dividers, per-cell drawer fronts with handles,
  z-fighting fix between dividers and back panel, stale drawerCells pruned on grid shrink
- AC_UNIT: new 'lg' style with dark vent, grille lines, deflector flap, LG accent
- SPEAKER: new 'soundbar' variant (90/110/140cm) with grille, driver cones, LED strip;
  SpeakerMesh split into dispatcher + SpeakerCabinetMesh to keep hook order stable
- Wall light: new 'wood-cube' style (wooden block sconce with top/bottom glow)
- PropertiesPanel: tf() helper replaces repeated i18n.exists fallbacks; inline checkbox
  styles consolidated into styles.checkboxLabel
- Translations: en/ru entries for new property keys
This commit is contained in:
2026-04-16 03:06:08 +03:00
parent 5fbd382120
commit c808bf1add
10 changed files with 523 additions and 18 deletions
@@ -237,6 +237,13 @@
"properties.windowSlopeDepth": "Reveal depth",
"properties.openingFrameThickness": "Frame thickness",
"properties.shelfRows": "Shelf rows",
"properties.shelfColumns": "Shelf columns",
"properties.drawerCells": "Drawers",
"properties.legs": "Legs",
"properties.legHeight": "Leg height",
"properties.acUnitStyleLabel": "Style",
"properties.acUnitStyle.generic": "Generic",
"properties.acUnitStyle.lg": "LG",
"properties.hasBackPanel": "Back panel",
"properties.curtainOpen": "Open",
"properties.curtainLeftOpen": "Left open",
@@ -240,6 +240,13 @@
"properties.windowSlopeDepth": "Глубина откоса",
"properties.openingFrameThickness": "Толщина рамы",
"properties.shelfRows": "Количество полок",
"properties.shelfColumns": "Количество колонок",
"properties.drawerCells": "Ящики",
"properties.legs": "Ножки",
"properties.legHeight": "Высота ножек",
"properties.acUnitStyleLabel": "Стиль",
"properties.acUnitStyle.generic": "Обычный",
"properties.acUnitStyle.lg": "LG",
"properties.hasBackPanel": "Задняя стенка",
"properties.curtainOpen": "Раскрытие",
"properties.curtainLeftOpen": "Левая створка",
@@ -1,8 +1,8 @@
import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor, WallLightStyle, FurnitureTexture, OutletDirection } from '@house-plan-maker/shared';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor, WallLightStyle, FurnitureTexture, OutletDirection, AcUnitStyle } from '@house-plan-maker/shared';
import { TextPromptModal } from '../ui/TextPromptModal';
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS, WALL_LIGHT_STYLES, OUTLET_DIRECTIONS, FURNITURE_TEXTURES, TEXTURABLE_FURNITURE } from '@house-plan-maker/shared';
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS, WALL_LIGHT_STYLES, OUTLET_DIRECTIONS, AC_UNIT_STYLES, FURNITURE_TEXTURES, TEXTURABLE_FURNITURE } from '@house-plan-maker/shared';
import { useEditor } from './context/EditorContext';
import { useUndoRedo } from './context/UndoRedoContext';
import { wallLength } from './utils/wallUtils';
@@ -23,12 +23,26 @@ import {
getCurtainRightOpen,
getCurtainFabricColor,
} from './utils/curtainMetadata';
import { getFurnitureTexture } from './utils/furnitureTextureMetadata';
import { getFurnitureTexture, getAcUnitStyle } from './utils/furnitureTextureMetadata';
import type { EditorCommand } from './types';
import styles from './properties-panel.module.css';
const PROPERTIES_COLLAPSED_KEY = 'editor.propertiesPanel.collapsed';
/**
* Resolve a translation key with a hard-coded English fallback used when
* the key is missing from the active locale. Keeps the call-site terse
* compared with the inline `i18n.exists(k) ? t(k) : 'fallback'` pattern.
*/
function tf(
i18n: { exists: (k: string) => boolean },
t: (k: string) => string,
key: string,
fallback: string,
): string {
return i18n.exists(key) ? t(key) : fallback;
}
function readCollapsed(): boolean {
try {
return localStorage.getItem(PROPERTIES_COLLAPSED_KEY) === 'true';
@@ -1277,8 +1291,63 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
/>
<FurnitureOpacitySlider item={item} />
<FurnitureProjectionToggle item={item} />
{item.type === 'NIGHTSTAND' && (
<>
<div className={styles.row}>
<span className={styles.rowLabel}>{tf(i18n, t, 'properties.legs', 'Legs')}</span>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={(item.metadata?.['hasLegs'] as boolean | undefined) ?? false}
onChange={(e) => {
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), hasLegs: e.target.checked },
});
}}
/>
{t('properties.yes')}
</label>
</div>
{(item.metadata?.['hasLegs'] as boolean | undefined) && (
<EditablePropertyRow
label={tf(i18n, t, 'properties.legHeight', 'Leg height')}
value={String(Math.round(((item.metadata?.['legHeight'] as number | undefined) ?? 0.12) * 1000) / 1000)}
unit="m"
onCommit={(value) => {
const num = parseFloat(value);
// Cap legs at 60% of total height so the body stays a
// recognisable cabinet rather than collapsing to a plank.
const maxLegHeight = item.height * 0.6;
if (!isNaN(num) && num > 0 && num <= maxLegHeight) {
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), legHeight: num },
});
}
}}
/>
)}
</>
)}
{item.type === 'CURTAIN' && <CurtainControls item={item} onUpdate={onUpdate} />}
{item.type === 'BOOKCASE' && <BookcaseControls item={item} onUpdate={onUpdate} />}
{item.type === 'AC_UNIT' && (
<SelectPropertyRow<AcUnitStyle>
label={tf(i18n, t, 'properties.acUnitStyleLabel', 'Style')}
value={getAcUnitStyle(item.metadata)}
options={AC_UNIT_STYLES.map((s) => ({
value: s,
label: tf(i18n, t, `properties.acUnitStyle.${s}`, s === 'generic' ? 'Generic' : 'LG'),
}))}
onChange={(style) =>
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), acUnitStyle: style },
})
}
/>
)}
{item.type === 'TV' && (
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.stand')}</span>
@@ -1462,12 +1531,10 @@ interface BookcaseControlsProps {
function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
const { t, i18n } = useTranslation();
const rowsLabel = i18n.exists('properties.shelfRows')
? t('properties.shelfRows')
: 'Shelf rows';
const backPanelLabel = i18n.exists('properties.hasBackPanel')
? t('properties.hasBackPanel')
: 'Back panel';
const rowsLabel = tf(i18n, t, 'properties.shelfRows', 'Shelf rows');
const columnsLabel = tf(i18n, t, 'properties.shelfColumns', 'Shelf columns');
const backPanelLabel = tf(i18n, t, 'properties.hasBackPanel', 'Back panel');
const drawersLabel = tf(i18n, t, 'properties.drawerCells', 'Drawers');
const metadataRows = item.metadata?.['shelfRows'];
const currentRows =
@@ -1475,9 +1542,21 @@ function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
? Math.round(metadataRows)
: Math.max(2, Math.round(item.height / 0.35));
const metadataColumns = item.metadata?.['shelfColumns'];
const currentColumns =
typeof metadataColumns === 'number' && metadataColumns >= 1
? Math.round(metadataColumns)
: 1;
const metadataHasBack = item.metadata?.['hasBackPanel'];
const hasBack = typeof metadataHasBack === 'boolean' ? metadataHasBack : true;
const metadataDrawers = item.metadata?.['drawerCells'];
const drawerCells: Record<string, boolean> =
metadataDrawers && typeof metadataDrawers === 'object' && !Array.isArray(metadataDrawers)
? (metadataDrawers as Record<string, boolean>)
: {};
const updateMetadata = useCallback(
(patch: Record<string, unknown>) => {
const next = { ...(item.metadata ?? {}), ...patch };
@@ -1486,6 +1565,30 @@ function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
[item, onUpdate],
);
/**
* When the grid shrinks, drop any `drawerCells` keys that no longer
* refer to a visible cell so a subsequent grow doesn't bring back
* ghost drawers the user doesn't see in the editor.
*/
const applyGridChange = useCallback(
(patch: { shelfRows?: number; shelfColumns?: number }) => {
const nextRows = patch.shelfRows ?? currentRows;
const nextCols = patch.shelfColumns ?? currentColumns;
const pruned: Record<string, boolean> = {};
for (const [key, value] of Object.entries(drawerCells)) {
if (value !== true) continue;
const [rStr, cStr] = key.split('_');
const r = parseInt(rStr ?? '', 10);
const c = parseInt(cStr ?? '', 10);
if (Number.isFinite(r) && Number.isFinite(c) && r < nextRows && c < nextCols) {
pruned[key] = true;
}
}
updateMetadata({ ...patch, drawerCells: pruned });
},
[currentRows, currentColumns, drawerCells, updateMetadata],
);
return (
<>
<EditablePropertyRow
@@ -1494,13 +1597,23 @@ function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 12) {
updateMetadata({ shelfRows: n });
applyGridChange({ shelfRows: n });
}
}}
/>
<EditablePropertyRow
label={columnsLabel}
value={String(currentColumns)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 12) {
applyGridChange({ shelfColumns: n });
}
}}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{backPanelLabel}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={hasBack}
@@ -1509,6 +1622,45 @@ function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
{t('properties.yes')}
</label>
</div>
<div className={styles.row} style={{ alignItems: 'flex-start' }}>
<span className={styles.rowLabel}>{drawersLabel}</span>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${currentColumns}, 18px)`,
gap: 2,
}}
>
{Array.from({ length: currentRows })
.flatMap((_, rFromTop) => {
// Display top row first, but store with row 0 = bottom to
// match the 3D mesh's y-axis indexing.
const r = currentRows - 1 - rFromTop;
return Array.from({ length: currentColumns }).map((__, c) => {
const key = `${r}_${c}`;
const checked = drawerCells[key] === true;
return (
<input
key={key}
type="checkbox"
checked={checked}
title={`row ${rFromTop + 1} / col ${c + 1}`}
onChange={(e) => {
const next = { ...drawerCells };
if (e.target.checked) {
next[key] = true;
} else {
delete next[key];
}
updateMetadata({ drawerCells: next });
}}
style={{ width: 18, height: 18, margin: 0, cursor: 'pointer' }}
/>
);
});
})}
</div>
</div>
</>
);
}
@@ -169,3 +169,10 @@
border-color: var(--color-accent-500);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.checkboxLabel {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
@@ -110,6 +110,13 @@ export const FURNITURE_DEFS: readonly FurnitureDef[] = [
{ type: 'SPEAKER', category: 'electronics', label: 'Shelf Speaker (L)', width: 0.26, depth: 0.32, height: 0.44, icon: '\u{1F50A}', defaultMetadata: { variant: 'shelf' } },
{ type: 'SPEAKER', category: 'electronics', label: 'Floor Speaker', width: 0.25, depth: 0.35, height: 1.0, icon: '\u{1F50A}', defaultMetadata: { variant: 'floor' } },
{ type: 'SPEAKER', category: 'electronics', label: 'Floor Speaker (L)', width: 0.3, depth: 0.4, height: 1.2, icon: '\u{1F50A}', defaultMetadata: { variant: 'floor' } },
// TV soundbars — long horizontal speakers that pair with a TV. Width
// roughly tracks the associated TV size (~90cm for 43", ~110cm for 55",
// ~140cm for 65"+). Height is ~68cm, depth ~910cm. Elevation is
// typically set so the bar sits just below the TV (set when placed).
{ type: 'SPEAKER', category: 'electronics', label: 'Soundbar 90', width: 0.9, depth: 0.09, height: 0.06, icon: '\u{1F50A}', defaultMetadata: { variant: 'soundbar' } },
{ type: 'SPEAKER', category: 'electronics', label: 'Soundbar 110', width: 1.1, depth: 0.1, height: 0.07, icon: '\u{1F50A}', defaultMetadata: { variant: 'soundbar' } },
{ type: 'SPEAKER', category: 'electronics', label: 'Soundbar 140', width: 1.4, depth: 0.1, height: 0.08, icon: '\u{1F50A}', defaultMetadata: { variant: 'soundbar' } },
// PC tower / desktop case. Dimensions match typical ATX mid-tower (~20cm
// wide, ~45cm deep, ~45cm tall) and mini-tower variants. Sits on the
// floor or under a desk — default elevation 0.
@@ -356,6 +356,70 @@ function WallLightSconceDown({ color, lampSize }: { readonly color: string; read
);
}
/** Wood cube wall sconce: a wooden block mounted on the wall emitting warm light up and down. */
function WallLightWoodCube({ color, lampSize }: { readonly color: string; readonly lampSize: number }) {
const scale = lampSize / 0.12;
const cubeW = 0.12 * scale;
const cubeH = 0.12 * scale;
const cubeD = 0.10 * scale;
const slitH = 0.008 * scale;
const glowRadius = 0.02 * scale;
return (
<group>
{/* Wooden cube body */}
<mesh position={[0, 0, cubeD / 2]} castShadow>
<boxGeometry args={[cubeW, cubeH, cubeD]} />
<meshStandardMaterial color="#b08050" roughness={0.75} metalness={0.0} />
</mesh>
{/* Top light slit glow */}
<mesh position={[0, cubeH / 2 + slitH, cubeD / 2]}>
<boxGeometry args={[cubeW * 0.85, slitH * 2, cubeD * 0.6]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.6}
transparent
opacity={0.7}
/>
</mesh>
{/* Bottom light slit glow */}
<mesh position={[0, -cubeH / 2 - slitH, cubeD / 2]}>
<boxGeometry args={[cubeW * 0.85, slitH * 2, cubeD * 0.6]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.6}
transparent
opacity={0.7}
/>
</mesh>
{/* Glow sphere above */}
<mesh position={[0, cubeH / 2 + glowRadius * 2, cubeD / 2]}>
<sphereGeometry args={[glowRadius, 8, 8]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.3}
transparent
opacity={0.5}
/>
</mesh>
{/* Glow sphere below */}
<mesh position={[0, -cubeH / 2 - glowRadius * 2, cubeD / 2]}>
<sphereGeometry args={[glowRadius, 8, 8]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.3}
transparent
opacity={0.5}
/>
</mesh>
</group>
);
}
/** Wall light: renders the appropriate style variant */
function WallLightMesh({ color, style, cordLength, lampSize }: {
readonly color: string;
@@ -370,6 +434,8 @@ function WallLightMesh({ color, style, cordLength, lampSize }: {
return <WallLightSconceUp color={color} lampSize={lampSize} />;
case 'sconce-down':
return <WallLightSconceDown color={color} lampSize={lampSize} />;
case 'wood-cube':
return <WallLightWoodCube color={color} lampSize={lampSize} />;
case 'classic':
default:
return <WallLightClassic color={color} lampSize={lampSize} />;
@@ -3,7 +3,7 @@ import * as THREE from 'three';
import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared';
import { rotatedAnchorOffsetToCenter, TEXTURABLE_FURNITURE } from '@house-plan-maker/shared';
import { getCurtainLeftOpen, getCurtainRightOpen, getCurtainFabricColor } from '../utils/curtainMetadata';
import { getFurnitureTexture } from '../utils/furnitureTextureMetadata';
import { getFurnitureTexture, getAcUnitStyle } from '../utils/furnitureTextureMetadata';
import { getFurnitureSurfacePbr, computeTextureRepeat } from './utils/pbrTextures';
/**
@@ -66,6 +66,26 @@ const LEG_SEGMENTS = 6;
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}`;
@@ -296,7 +316,7 @@ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly col
);
}
/** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */
/** 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);
@@ -307,6 +327,104 @@ function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly
);
}
/** 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.
*
@@ -332,11 +450,35 @@ function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly
// 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>
@@ -364,11 +506,57 @@ function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly
{Array.from({ length: shelfRows + 1 }).map((_, i) => {
const y = (i / shelfRows) * item.height;
return (
<mesh key={i} position={[0, y, 0]} castShadow material={material}>
<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>
);
}
@@ -1170,6 +1358,57 @@ function DigitalPianoMesh({ item, color }: { readonly item: FurnitureItem; reado
);
}
/**
* 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`:
*
@@ -1189,8 +1428,14 @@ function DigitalPianoMesh({ item, color }: { readonly item: FurnitureItem; reado
function SpeakerMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const variantRaw = item.metadata?.['variant'];
const variant = typeof variantRaw === 'string' ? variantRaw.toLowerCase() : 'shelf';
const isFloor = variant === 'floor';
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).
@@ -1383,7 +1628,7 @@ function getFurnitureComponent(type: FurnitureType) {
case 'BOOKCASE': return BookcaseMesh;
case 'TV': return TvMesh;
case 'PC_TOWER': return PcTowerMesh;
case 'AC_UNIT': return SimpleBoxMesh;
case 'AC_UNIT': return AcUnitMesh;
case 'RADIATOR': return RadiatorMesh;
case 'WALL_COLLAGE': return WallCollageMesh;
case 'CURTAIN': return CurtainMesh;
@@ -1392,8 +1637,8 @@ function getFurnitureComponent(type: FurnitureType) {
case 'MIRROR': return MirrorMesh;
case 'DIGITAL_PIANO': return DigitalPianoMesh;
case 'SPEAKER': return SpeakerMesh;
case 'NIGHTSTAND': return NightstandMesh;
case 'SHELF':
case 'NIGHTSTAND':
case 'DRESSER':
case 'OTHER':
default:
@@ -8,7 +8,7 @@
* When absent or 'NONE', the furniture uses its default solid-color material.
*/
import type { FurnitureTexture } from '@house-plan-maker/shared';
import type { FurnitureTexture, AcUnitStyle } from '@house-plan-maker/shared';
type MetadataBag = Record<string, unknown> | null | undefined;
@@ -21,3 +21,11 @@ export function getFurnitureTexture(metadata: MetadataBag): FurnitureTexture {
}
return 'NONE';
}
/** Read the AC unit style from furniture metadata. Returns 'generic' by default. */
export function getAcUnitStyle(metadata: MetadataBag): AcUnitStyle {
if (!metadata) return 'generic';
const raw = metadata['acUnitStyle'];
if (raw === 'lg') return 'lg';
return 'generic';
}