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:
@@ -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 ~6–8cm, depth ~9–10cm. 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';
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export type {
|
||||
PositionAnchor,
|
||||
ElectricalType,
|
||||
OutletDirection,
|
||||
AcUnitStyle,
|
||||
WallLightStyle,
|
||||
FurnitureTexture,
|
||||
ElectricalItem,
|
||||
@@ -66,6 +67,7 @@ export {
|
||||
DOOR_OPEN_DIRECTIONS,
|
||||
ELECTRICAL_TYPES,
|
||||
OUTLET_DIRECTIONS,
|
||||
AC_UNIT_STYLES,
|
||||
WALL_LIGHT_STYLES,
|
||||
FURNITURE_TEXTURES,
|
||||
TEXTURABLE_FURNITURE,
|
||||
|
||||
@@ -228,11 +228,15 @@ export type ElectricalType = (typeof ELECTRICAL_TYPES)[number];
|
||||
export const OUTLET_DIRECTIONS = ['horizontal', 'vertical'] as const;
|
||||
export type OutletDirection = (typeof OUTLET_DIRECTIONS)[number];
|
||||
|
||||
export const AC_UNIT_STYLES = ['generic', 'lg'] as const;
|
||||
export type AcUnitStyle = (typeof AC_UNIT_STYLES)[number];
|
||||
|
||||
export const WALL_LIGHT_STYLES = [
|
||||
'classic',
|
||||
'pendant-globe',
|
||||
'sconce-up',
|
||||
'sconce-down',
|
||||
'wood-cube',
|
||||
] as const;
|
||||
export type WallLightStyle = (typeof WALL_LIGHT_STYLES)[number];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user