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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user