feat: add outlet direction (horizontal/vertical), wall light styles, floor textures, and stretch ceiling

- Add configurable outlet direction (horizontal/vertical) stored in metadata
- Add wall light style variants (classic, pendant-globe, sconce-up, sconce-down)
- Add PBR floor textures including natural oak
- Add stretch ceiling offset support with DB migration
- Add furniture surface texture selection
- Add canvas theme colors utility for dark mode support
- Update projection views with improved rendering
- Add EN and RU translations for all new properties
This commit is contained in:
2026-04-12 20:52:49 +03:00
parent d8a914bf2a
commit 521ea5e85b
34 changed files with 1278 additions and 162 deletions
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared';
import { rotatedAnchorOffsetToCenter } 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 { getFurnitureSurfacePbr, computeTextureRepeat } from './utils/pbrTextures';
/**
* Below this opacity threshold, furniture meshes stop casting shadows. Cast
@@ -86,6 +88,37 @@ export function setFurnitureGlobalOpacity(_opacity: number): void {
// intentionally empty — per-item material clones own their opacity now
}
/**
* Returns a material for furniture surfaces: either a PBR textured material
* (when the item has a surfaceTexture set) or the solid-color fallback.
* The PBR material's UV repeat is scaled for the given surface dimensions.
*/
function useSurfaceMaterial(
item: FurnitureItem,
solidColor: string,
solidRoughness: number,
surfaceWidth: number,
surfaceDepth: number,
): THREE.MeshStandardMaterial {
return useMemo(() => {
if (!TEXTURABLE_FURNITURE.includes(item.type)) {
return getFurnitureMaterial(solidColor, solidRoughness);
}
const texture = getFurnitureTexture(item.metadata);
const pbr = getFurnitureSurfacePbr(texture);
if (!pbr) {
return getFurnitureMaterial(solidColor, solidRoughness);
}
// Clone the PBR material so UV repeat is per-item, not global.
const mat = pbr.material.clone();
const repeat = computeTextureRepeat(surfaceWidth, surfaceDepth, pbr.tileMeters);
if (mat.map) { mat.map = mat.map.clone(); mat.map.repeat.set(repeat.u, repeat.v); }
if (mat.normalMap) { mat.normalMap = mat.normalMap.clone(); mat.normalMap.repeat.set(repeat.u, repeat.v); }
if (mat.roughnessMap) { mat.roughnessMap = mat.roughnessMap.clone(); mat.roughnessMap.repeat.set(repeat.u, repeat.v); }
return mat;
}, [item.type, item.metadata, solidColor, solidRoughness, surfaceWidth, surfaceDepth]);
}
// ── Shared geometries for common shapes ──
const legGeometry = new THREE.CylinderGeometry(LEG_RADIUS, LEG_RADIUS, 1, LEG_SEGMENTS);
@@ -101,11 +134,13 @@ function BedMesh({ item, color }: { readonly item: FurnitureItem; readonly color
const frameHeight = item.height * 0.3;
const headboardHeight = item.height;
const mattressMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]);
const frameMaterial = useSurfaceMaterial(item, LEG_COLOR, 0.6, item.width, item.depth);
const headboardMaterial = useSurfaceMaterial(item, LEG_COLOR, 0.5, item.width, headboardHeight);
return (
<group>
{/* Frame */}
<mesh position={[0, frameHeight / 2, 0]} castShadow material={legMaterial}>
<mesh position={[0, frameHeight / 2, 0]} castShadow material={frameMaterial}>
<boxGeometry args={[item.width, frameHeight, item.depth]} />
</mesh>
{/* Mattress */}
@@ -113,7 +148,7 @@ function BedMesh({ item, color }: { readonly item: FurnitureItem; readonly color
<boxGeometry args={[item.width * 0.95, mattressHeight, item.depth * 0.95]} />
</mesh>
{/* Headboard */}
<mesh position={[0, headboardHeight / 2, -item.depth / 2 + 0.02]} castShadow material={legMaterialSmooth}>
<mesh position={[0, headboardHeight / 2, -item.depth / 2 + 0.02]} castShadow material={headboardMaterial}>
<boxGeometry args={[item.width, headboardHeight, 0.04]} />
</mesh>
</group>
@@ -125,7 +160,7 @@ function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
const topThickness = 0.04;
const legHeight = item.height - topThickness;
const inset = 0.05;
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
return (
<group>
@@ -150,7 +185,7 @@ function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
/** Wardrobe: tall box with slight door line */
function WardrobeMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const bodyMaterial = useSurfaceMaterial(item, color, 0.6, item.width, item.height);
return (
<group>
@@ -207,7 +242,7 @@ function TableMesh({ item, color }: { readonly item: FurnitureItem; readonly col
const topThickness = 0.03;
const legHeight = item.height - topThickness;
const inset = 0.05;
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
return (
<group>
@@ -234,7 +269,7 @@ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly col
const seatThickness = 0.03;
const legHeight = seatHeight - seatThickness;
const inset = 0.03;
const chairMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const chairMaterial = useSurfaceMaterial(item, color, 0.6, item.width, item.depth);
return (
<group>
@@ -263,7 +298,7 @@ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly col
/** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */
function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const material = useSurfaceMaterial(item, color, 0.6, item.width, item.depth);
return (
<mesh position={[0, item.height / 2, 0]} castShadow material={material}>
@@ -301,7 +336,7 @@ function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly
const hasBackPanel = typeof hasBackPanelRaw === 'boolean' ? hasBackPanelRaw : true;
const panelThickness = 0.02;
const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const material = useSurfaceMaterial(item, color, 0.6, item.width, item.height);
return (
<group>
@@ -589,7 +624,7 @@ function CurtainMesh({ item, color: _defaultColor }: { readonly item: FurnitureI
* looking sparse.
*/
function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.55), [color]);
const frameMaterial = useSurfaceMaterial(item, color, 0.55, item.width, item.depth);
const mattressMaterial = useMemo(() => getFurnitureMaterial('#f4eadf', 0.9), []);
const mattressThick = 0.08;
@@ -710,7 +745,7 @@ function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
* upright panel.
*/
function DressingTableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
const bodyMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
const mirrorMaterial = useMemo(() => getFurnitureMaterial('#b8d0d8', 0.05), []);
const mirrorFrameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]);