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
@@ -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: