diff --git a/apps/client/public/locales/en/translation.json b/apps/client/public/locales/en/translation.json
index 2dcced8..fc0c866 100644
--- a/apps/client/public/locales/en/translation.json
+++ b/apps/client/public/locales/en/translation.json
@@ -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",
diff --git a/apps/client/public/locales/ru/translation.json b/apps/client/public/locales/ru/translation.json
index 598b656..d4d0afb 100644
--- a/apps/client/public/locales/ru/translation.json
+++ b/apps/client/public/locales/ru/translation.json
@@ -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": "Левая створка",
diff --git a/apps/client/src/components/editor/PropertiesPanel.tsx b/apps/client/src/components/editor/PropertiesPanel.tsx
index 326e6de..bf8b611 100644
--- a/apps/client/src/components/editor/PropertiesPanel.tsx
+++ b/apps/client/src/components/editor/PropertiesPanel.tsx
@@ -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) {
/>
+ {item.type === 'NIGHTSTAND' && (
+ <>
+
+ {tf(i18n, t, 'properties.legs', 'Legs')}
+
+
+ {(item.metadata?.['hasLegs'] as boolean | undefined) && (
+ {
+ 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' && }
{item.type === 'BOOKCASE' && }
+ {item.type === 'AC_UNIT' && (
+
+ 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' && (
{t('properties.stand')}
@@ -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
=
+ metadataDrawers && typeof metadataDrawers === 'object' && !Array.isArray(metadataDrawers)
+ ? (metadataDrawers as Record)
+ : {};
+
const updateMetadata = useCallback(
(patch: Record) => {
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 = {};
+ 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 (
<>
{
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 12) {
- updateMetadata({ shelfRows: n });
+ applyGridChange({ shelfRows: n });
+ }
+ }}
+ />
+ {
+ const n = parseInt(v, 10);
+ if (!isNaN(n) && n >= 1 && n <= 12) {
+ applyGridChange({ shelfColumns: n });
}
}}
/>
{backPanelLabel}
-
+
+
{drawersLabel}
+
+ {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 (
+ {
+ 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' }}
+ />
+ );
+ });
+ })}
+
+
>
);
}
diff --git a/apps/client/src/components/editor/properties-panel.module.css b/apps/client/src/components/editor/properties-panel.module.css
index b588e96..ae5c353 100644
--- a/apps/client/src/components/editor/properties-panel.module.css
+++ b/apps/client/src/components/editor/properties-panel.module.css
@@ -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;
+}
diff --git a/apps/client/src/components/editor/symbols/furniture/index.ts b/apps/client/src/components/editor/symbols/furniture/index.ts
index 4094c75..8d9cc1a 100644
--- a/apps/client/src/components/editor/symbols/furniture/index.ts
+++ b/apps/client/src/components/editor/symbols/furniture/index.ts
@@ -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.
diff --git a/apps/client/src/components/editor/three/ElectricalMesh.tsx b/apps/client/src/components/editor/three/ElectricalMesh.tsx
index 984a9f6..b3abe73 100644
--- a/apps/client/src/components/editor/three/ElectricalMesh.tsx
+++ b/apps/client/src/components/editor/three/ElectricalMesh.tsx
@@ -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 (
+
+ {/* Wooden cube body */}
+
+
+
+
+ {/* Top light slit glow */}
+
+
+
+
+ {/* Bottom light slit glow */}
+
+
+
+
+ {/* Glow sphere above */}
+
+
+
+
+ {/* Glow sphere below */}
+
+
+
+
+
+ );
+}
+
/** 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 ;
case 'sconce-down':
return ;
+ case 'wood-cube':
+ return ;
case 'classic':
default:
return ;
diff --git a/apps/client/src/components/editor/three/FurnitureMesh.tsx b/apps/client/src/components/editor/three/FurnitureMesh.tsx
index 31c72fc..f7b6047 100644
--- a/apps/client/src/components/editor/three/FurnitureMesh.tsx
+++ b/apps/client/src/components/editor/three/FurnitureMesh.tsx
@@ -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 = {};
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 (
+
+ {/* Body */}
+
+
+
+ {/* Legs */}
+ {hasLegs && legHeight > 0 && (
+ <>
+ {([
+ [-insetX, -insetZ],
+ [insetX, -insetZ],
+ [-insetX, insetZ],
+ [insetX, insetZ],
+ ] as const).map(([x, z], i) => (
+
+
+
+
+ ))}
+ >
+ )}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Main body — white casing */}
+
+
+
+
+ {/* Dark air vent recess */}
+
+
+
+
+ {/* Vent grille lines */}
+ {Array.from({ length: 5 }).map((_, i) => {
+ const yOff = ventH * 0.15 + (i / 4) * ventH * 0.7;
+ return (
+
+
+
+
+ );
+ })}
+ {/* Deflector flap — angled open at the bottom of the vent */}
+
+
+
+
+
+
+ {/* LG logo indicator — small dark rectangle on upper body */}
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 ;
+ }
+ return ;
+}
+
/**
* 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)
+ : {};
+
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 (
@@ -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 (
-
+
);
})}
+ {/* 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 (
+
+
+
+ );
+ })}
+ {/* 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 (
+
+
+
+
+ {/* Handle — a dark bar stands out against wood/paint. */}
+
+
+
+
+ );
+ }),
+ )}
);
}
@@ -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 (
+
+ {/* Cabinet */}
+
+
+
+ {/* Front grille — thin dark strip covering most of the front face */}
+
+
+
+ {/* 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 (
+
+
+
+ );
+ })}
+ {/* LED accent strip along the bottom edge */}
+
+
+
+
+ );
+}
+
/**
* 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 ;
+ }
+ return ;
+}
+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:
diff --git a/apps/client/src/components/editor/utils/furnitureTextureMetadata.ts b/apps/client/src/components/editor/utils/furnitureTextureMetadata.ts
index 83c311f..5a6146e 100644
--- a/apps/client/src/components/editor/utils/furnitureTextureMetadata.ts
+++ b/apps/client/src/components/editor/utils/furnitureTextureMetadata.ts
@@ -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 | 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';
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 31ef3cd..a4d3a53 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -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,
diff --git a/packages/shared/src/types/elements.ts b/packages/shared/src/types/elements.ts
index 3424a8f..cd85301 100644
--- a/packages/shared/src/types/elements.ts
+++ b/packages/shared/src/types/elements.ts
@@ -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];