From c808bf1adde68151e7354449e73ddbe3fe8b54d5 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 03:06:08 +0300 Subject: [PATCH] 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 --- .../client/public/locales/en/translation.json | 7 + .../client/public/locales/ru/translation.json | 7 + .../src/components/editor/PropertiesPanel.tsx | 174 +++++++++++- .../editor/properties-panel.module.css | 7 + .../editor/symbols/furniture/index.ts | 7 + .../editor/three/ElectricalMesh.tsx | 66 +++++ .../components/editor/three/FurnitureMesh.tsx | 257 +++++++++++++++++- .../editor/utils/furnitureTextureMetadata.ts | 10 +- packages/shared/src/index.ts | 2 + packages/shared/src/types/elements.ts | 4 + 10 files changed, 523 insertions(+), 18 deletions(-) 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];