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:
@@ -149,6 +149,8 @@
|
||||
"toolbar.elec": "Elec",
|
||||
"toolbar.furn": "Furn",
|
||||
"toolbar.meas": "Meas",
|
||||
"toolbar.stretchCeiling": "Ceiling",
|
||||
"toolbar.toggleStretchCeiling": "Toggle stretch ceiling overlay",
|
||||
"toolbar.toggleGrid": "Toggle grid",
|
||||
"toolbar.toggleSnap": "Toggle snap",
|
||||
"toolbar.toggleWalls": "Toggle walls layer",
|
||||
@@ -222,6 +224,7 @@
|
||||
"floor.WOOD_MEDIUM": "Medium Wood",
|
||||
"floor.WOOD_DARK": "Dark Wood",
|
||||
"floor.WOOD_HERRINGBONE": "Herringbone",
|
||||
"floor.OAK_NATURAL": "Natural Oak",
|
||||
"floor.TILE_WHITE": "White Tile",
|
||||
"floor.TILE_GRAY": "Gray Tile",
|
||||
"floor.LAMINATE": "Laminate",
|
||||
@@ -242,6 +245,28 @@
|
||||
"properties.outletWidth": "Outlet width",
|
||||
"properties.outletHeight": "Outlet height",
|
||||
"properties.outletCount": "Count",
|
||||
"properties.outletDirectionLabel": "Direction",
|
||||
"properties.outletDirection.horizontal": "Horizontal",
|
||||
"properties.outletDirection.vertical": "Vertical",
|
||||
"properties.stretchCeilingOffset": "Stretch ceiling drop",
|
||||
"properties.wallLightStyleLabel": "Style",
|
||||
"properties.wallLightStyle.classic": "Classic",
|
||||
"properties.wallLightStyle.pendant-globe": "Pendant Globe",
|
||||
"properties.wallLightStyle.sconce-up": "Sconce Up",
|
||||
"properties.wallLightStyle.sconce-down": "Sconce Down",
|
||||
"properties.cordLength": "Cord length",
|
||||
"properties.lampSize": "Lamp size",
|
||||
"properties.surfaceTexture": "Surface",
|
||||
"furnitureTexture.NONE": "None (solid color)",
|
||||
"furnitureTexture.WOOD_LIGHT": "Light Wood",
|
||||
"furnitureTexture.WOOD_MEDIUM": "Medium Wood",
|
||||
"furnitureTexture.WOOD_DARK": "Dark Wood",
|
||||
"furnitureTexture.WOOD_HERRINGBONE": "Herringbone",
|
||||
"furnitureTexture.OAK_NATURAL": "Natural Oak",
|
||||
"furnitureTexture.LAMINATE": "Laminate",
|
||||
"furnitureTexture.CONCRETE": "Concrete",
|
||||
"properties.invertCoordX": "Invert X display",
|
||||
"properties.invertCoordY": "Invert Y display",
|
||||
"properties.anchor": "Anchor",
|
||||
"anchor.left": "Left",
|
||||
"anchor.middle": "Middle",
|
||||
|
||||
@@ -152,6 +152,8 @@
|
||||
"toolbar.elec": "Элек",
|
||||
"toolbar.furn": "Мебель",
|
||||
"toolbar.meas": "Разм",
|
||||
"toolbar.stretchCeiling": "Потолок",
|
||||
"toolbar.toggleStretchCeiling": "Показать/скрыть натяжной потолок",
|
||||
"toolbar.toggleGrid": "Переключить сетку",
|
||||
"toolbar.toggleSnap": "Переключить привязку",
|
||||
"toolbar.toggleWalls": "Переключить слой стен",
|
||||
@@ -225,6 +227,7 @@
|
||||
"floor.WOOD_MEDIUM": "Среднее дерево",
|
||||
"floor.WOOD_DARK": "Тёмное дерево",
|
||||
"floor.WOOD_HERRINGBONE": "Ёлочка",
|
||||
"floor.OAK_NATURAL": "Натуральный дуб",
|
||||
"floor.TILE_WHITE": "Белая плитка",
|
||||
"floor.TILE_GRAY": "Серая плитка",
|
||||
"floor.LAMINATE": "Ламинат",
|
||||
@@ -245,6 +248,28 @@
|
||||
"properties.outletWidth": "Ширина розетки",
|
||||
"properties.outletHeight": "Высота розетки",
|
||||
"properties.outletCount": "Количество",
|
||||
"properties.outletDirectionLabel": "Направление",
|
||||
"properties.outletDirection.horizontal": "Горизонтально",
|
||||
"properties.outletDirection.vertical": "Вертикально",
|
||||
"properties.stretchCeilingOffset": "Натяжной потолок (отступ)",
|
||||
"properties.wallLightStyleLabel": "Стиль",
|
||||
"properties.wallLightStyle.classic": "Классический",
|
||||
"properties.wallLightStyle.pendant-globe": "Подвесной шар",
|
||||
"properties.wallLightStyle.sconce-up": "Бра вверх",
|
||||
"properties.wallLightStyle.sconce-down": "Бра вниз",
|
||||
"properties.cordLength": "Длина шнура",
|
||||
"properties.lampSize": "Размер светильника",
|
||||
"properties.surfaceTexture": "Поверхность",
|
||||
"furnitureTexture.NONE": "Нет (сплошной цвет)",
|
||||
"furnitureTexture.WOOD_LIGHT": "Светлое дерево",
|
||||
"furnitureTexture.WOOD_MEDIUM": "Среднее дерево",
|
||||
"furnitureTexture.WOOD_DARK": "Тёмное дерево",
|
||||
"furnitureTexture.WOOD_HERRINGBONE": "Ёлочка",
|
||||
"furnitureTexture.OAK_NATURAL": "Натуральный дуб",
|
||||
"furnitureTexture.LAMINATE": "Ламинат",
|
||||
"furnitureTexture.CONCRETE": "Бетон",
|
||||
"properties.invertCoordX": "Инвертировать X",
|
||||
"properties.invertCoordY": "Инвертировать Y",
|
||||
"properties.anchor": "Привязка",
|
||||
"anchor.left": "Слева",
|
||||
"anchor.middle": "По центру",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -28,6 +28,7 @@ import { computeFurniturePreview, createFurnitureItemFromPlacement } from './too
|
||||
import { startMeasurement, updateMeasurement, finishMeasurement } from './tools/MeasureTool';
|
||||
import { ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
||||
import { FURNITURE_DEFS } from './symbols/furniture';
|
||||
import { useCanvasColors } from './utils/canvasThemeColors';
|
||||
import { AnnotationLayer } from './layers/AnnotationLayer';
|
||||
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
|
||||
import { generateLocalId } from './utils/geometry';
|
||||
@@ -53,6 +54,7 @@ const TOOL_CURSORS: Record<string, string> = {
|
||||
|
||||
export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
const canvasColors = useCanvasColors();
|
||||
const { zoom, panOffset, setZoom, setPanOffset } = useZoomPan();
|
||||
const {
|
||||
selectedIds,
|
||||
@@ -169,7 +171,11 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||
const worldPoint = getWorldPoint(e);
|
||||
|
||||
if (activeTool === 'select') {
|
||||
const hit = hitTest(worldPoint, openings, walls, electricalItems, furnitureItems);
|
||||
// When furniture is (nearly) fully transparent it's effectively
|
||||
// invisible — skip it in hit-testing so clicks fall through to
|
||||
// elements underneath instead of selecting a ghost.
|
||||
const hittableFurniture = globalFurnitureOpacity < 0.05 ? [] : furnitureItems;
|
||||
const hit = hitTest(worldPoint, openings, walls, electricalItems, hittableFurniture);
|
||||
|
||||
if (hit) {
|
||||
if (e.evt.shiftKey) {
|
||||
@@ -464,7 +470,9 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||
|
||||
// Only if the rectangle is non-trivial
|
||||
if (rect.width > 0.01 || rect.height > 0.01) {
|
||||
const ids = elementsInRect(rect, openings, walls, electricalItems, furnitureItems);
|
||||
// Skip furniture when globally transparent — see hit-test above.
|
||||
const selectableFurniture = globalFurnitureOpacity < 0.05 ? [] : furnitureItems;
|
||||
const ids = elementsInRect(rect, openings, walls, electricalItems, selectableFurniture);
|
||||
if (ids.size > 0) {
|
||||
selectionDispatch({ type: 'SET_SELECTED', ids });
|
||||
}
|
||||
@@ -509,7 +517,7 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{ cursor, background: '#ffffff' }}
|
||||
style={{ cursor, background: canvasColors.canvasBg }}
|
||||
>
|
||||
{/*
|
||||
Konva renders one HTML <canvas> per <Layer>; performance recommends 3-5
|
||||
|
||||
@@ -193,6 +193,16 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo
|
||||
>
|
||||
{t('toolbar.meas')}
|
||||
</button>
|
||||
<button
|
||||
className={[
|
||||
styles.toggleBtn,
|
||||
layerVisibility.stretchCeiling ? styles.toggleBtnActive : '',
|
||||
].join(' ')}
|
||||
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'stretchCeiling' })}
|
||||
title={t('toolbar.toggleStretchCeiling')}
|
||||
>
|
||||
{t('toolbar.stretchCeiling')}
|
||||
</button>
|
||||
<label
|
||||
title={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
|
||||
style={{
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor } from '@house-plan-maker/shared';
|
||||
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor, WallLightStyle, FurnitureTexture, OutletDirection } from '@house-plan-maker/shared';
|
||||
import { TextPromptModal } from '../ui/TextPromptModal';
|
||||
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS } from '@house-plan-maker/shared';
|
||||
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 { useEditor } from './context/EditorContext';
|
||||
import { useUndoRedo } from './context/UndoRedoContext';
|
||||
import { wallLength } from './utils/wallUtils';
|
||||
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
|
||||
import { normalizeAngleDegrees } from './utils/angle';
|
||||
import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
||||
import {
|
||||
getElectricalVariant,
|
||||
getWallLightStyle,
|
||||
getWallLightCordLength,
|
||||
getCeilingLampSize,
|
||||
ELECTRICAL_SYMBOL_DEFS,
|
||||
getOutletInvertCoordX,
|
||||
getOutletInvertCoordY,
|
||||
getOutletDirection,
|
||||
} from './symbols/electrical';
|
||||
import {
|
||||
getCurtainLeftOpen,
|
||||
getCurtainRightOpen,
|
||||
getCurtainFabricColor,
|
||||
} from './utils/curtainMetadata';
|
||||
import { getFurnitureTexture } from './utils/furnitureTextureMetadata';
|
||||
import type { EditorCommand } from './types';
|
||||
import styles from './properties-panel.module.css';
|
||||
|
||||
@@ -141,6 +151,22 @@ export function PropertiesPanel() {
|
||||
)}
|
||||
<PropertyRow label={t('properties.wallHeight')} value={`${room.wallHeight}m`} />
|
||||
<PropertyRow label={t('properties.plinthHeight')} value={`${Math.round(room.plinthHeight * 1000) / 10}cm`} />
|
||||
{/* Stretch ceiling drop (натяжной потолок). Stored in meters,
|
||||
edited in cm for ergonomics. 0 = disabled. */}
|
||||
<EditablePropertyRow
|
||||
label={t('properties.stretchCeilingOffset')}
|
||||
value={String(Math.round((room.stretchCeilingOffset ?? 0) * 1000) / 10)}
|
||||
unit="cm"
|
||||
onCommit={(v) => {
|
||||
const cm = parseFloat(v);
|
||||
if (!isNaN(cm) && cm >= 0 && cm <= 200) {
|
||||
dispatch({
|
||||
type: 'UPDATE_ROOM_PROPS',
|
||||
props: { stretchCeilingOffset: cm / 100 },
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SelectPropertyRow<FloorType>
|
||||
label={t('properties.floorType')}
|
||||
value={room.floorType}
|
||||
@@ -661,6 +687,25 @@ function PropertyRow({ label, value }: PropertyRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxPropertyRowProps {
|
||||
readonly label: string;
|
||||
readonly checked: boolean;
|
||||
readonly onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
function CheckboxPropertyRow({ label, checked, onChange }: CheckboxPropertyRowProps) {
|
||||
return (
|
||||
<label className={styles.row} style={{ cursor: 'pointer' }}>
|
||||
<span className={styles.rowLabel}>{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditablePropertyRowProps {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
@@ -876,8 +921,45 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||
|
||||
const isWallMounted = item.wallId !== null;
|
||||
const isOutlet = item.type === 'OUTLET';
|
||||
const isWallLight = item.type === 'LIGHT_WALL';
|
||||
const isCeilingLight = item.type === 'LIGHT_CEILING';
|
||||
const wallLightStyle = getWallLightStyle(item.metadata);
|
||||
const cordLength = getWallLightCordLength(item.metadata);
|
||||
const ceilingLampSize = getCeilingLampSize(item.metadata);
|
||||
const countLabel = i18n.exists('properties.outletCount') ? t('properties.outletCount') : 'Count';
|
||||
|
||||
const outletDirection = getOutletDirection(item.metadata);
|
||||
|
||||
const outletDirectionOptions = useMemo(
|
||||
() =>
|
||||
OUTLET_DIRECTIONS.map((d) => ({
|
||||
value: d,
|
||||
label: i18n.exists(`properties.outletDirection.${d}`)
|
||||
? t(`properties.outletDirection.${d}`)
|
||||
: d === 'horizontal'
|
||||
? 'Horizontal'
|
||||
: 'Vertical',
|
||||
})),
|
||||
[t, i18n],
|
||||
);
|
||||
|
||||
const wallLightStyleOptions = useMemo(
|
||||
() =>
|
||||
WALL_LIGHT_STYLES.map((s) => ({
|
||||
value: s,
|
||||
label: i18n.exists(`properties.wallLightStyle.${s}`)
|
||||
? t(`properties.wallLightStyle.${s}`)
|
||||
: s === 'classic'
|
||||
? 'Classic'
|
||||
: s === 'pendant-globe'
|
||||
? 'Pendant Globe'
|
||||
: s === 'sconce-up'
|
||||
? 'Sconce Up'
|
||||
: 'Sconce Down',
|
||||
})),
|
||||
[t, i18n],
|
||||
);
|
||||
|
||||
const handleXChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
@@ -927,6 +1009,51 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||
placeholder={labelPlaceholder}
|
||||
onChange={(label) => onUpdate({ ...item, label })}
|
||||
/>
|
||||
{isWallLight && (
|
||||
<SelectPropertyRow<WallLightStyle>
|
||||
label={i18n.exists('properties.wallLightStyleLabel') ? t('properties.wallLightStyleLabel') : 'Style'}
|
||||
value={wallLightStyle}
|
||||
options={wallLightStyleOptions}
|
||||
onChange={(style) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
metadata: { ...(item.metadata ?? {}), wallLightStyle: style },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isWallLight && wallLightStyle === 'pendant-globe' && (
|
||||
<EditablePropertyRow
|
||||
label={i18n.exists('properties.cordLength') ? t('properties.cordLength') : 'Cord length'}
|
||||
value={String(Math.round(cordLength * 1000) / 1000)}
|
||||
unit="m"
|
||||
onCommit={(v) => {
|
||||
const num = parseFloat(v);
|
||||
if (!isNaN(num) && num >= 0.05 && num <= 2.0) {
|
||||
onUpdate({
|
||||
...item,
|
||||
metadata: { ...(item.metadata ?? {}), cordLength: num },
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(isCeilingLight || isWallLight) && (
|
||||
<EditablePropertyRow
|
||||
label={i18n.exists('properties.lampSize') ? t('properties.lampSize') : 'Lamp size'}
|
||||
value={String(Math.round(ceilingLampSize * 1000) / 1000)}
|
||||
unit="m"
|
||||
onCommit={(v) => {
|
||||
const num = parseFloat(v);
|
||||
if (!isNaN(num) && num >= 0.05 && num <= 1.0) {
|
||||
onUpdate({
|
||||
...item,
|
||||
metadata: { ...(item.metadata ?? {}), lampSize: num },
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOutlet && (
|
||||
<EditablePropertyRow
|
||||
label={countLabel}
|
||||
@@ -937,6 +1064,43 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOutlet && (
|
||||
<SelectPropertyRow<OutletDirection>
|
||||
label={i18n.exists('properties.outletDirectionLabel') ? t('properties.outletDirectionLabel') : 'Direction'}
|
||||
value={outletDirection}
|
||||
options={outletDirectionOptions}
|
||||
onChange={(dir) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
metadata: { ...(item.metadata ?? {}), outletDirection: dir },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isOutlet && (
|
||||
<>
|
||||
<CheckboxPropertyRow
|
||||
label={t('properties.invertCoordX')}
|
||||
checked={getOutletInvertCoordX(item.metadata)}
|
||||
onChange={(checked) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
metadata: { ...(item.metadata ?? {}), invertCoordX: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CheckboxPropertyRow
|
||||
label={t('properties.invertCoordY')}
|
||||
checked={getOutletInvertCoordY(item.metadata)}
|
||||
onChange={(checked) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
metadata: { ...(item.metadata ?? {}), invertCoordY: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
|
||||
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
|
||||
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
|
||||
@@ -989,8 +1153,23 @@ function preserveFurnitureMarkers(originalLabel: string | null, newDisplay: stri
|
||||
}
|
||||
|
||||
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const displayLabel = stripFurnitureMarkers(item.label);
|
||||
const isTexturable = TEXTURABLE_FURNITURE.includes(item.type);
|
||||
const currentTexture = getFurnitureTexture(item.metadata);
|
||||
|
||||
const textureOptions = useMemo(
|
||||
() =>
|
||||
FURNITURE_TEXTURES.map((tex) => ({
|
||||
value: tex,
|
||||
label: i18n.exists(`furnitureTexture.${tex}`)
|
||||
? t(`furnitureTexture.${tex}`)
|
||||
: tex === 'NONE'
|
||||
? 'None (solid color)'
|
||||
: tex.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
})),
|
||||
[t, i18n],
|
||||
);
|
||||
// Furniture's "default" label for the placeholder is the type code; we
|
||||
// don't have access to the original FurnitureDef from a placed item.
|
||||
const labelPlaceholder = item.type;
|
||||
@@ -1064,6 +1243,19 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||
onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) })
|
||||
}
|
||||
/>
|
||||
{isTexturable && (
|
||||
<SelectPropertyRow<FurnitureTexture>
|
||||
label={i18n.exists('properties.surfaceTexture') ? t('properties.surfaceTexture') : 'Surface'}
|
||||
value={currentTexture}
|
||||
options={textureOptions}
|
||||
onChange={(tex) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
metadata: { ...(item.metadata ?? {}), surfaceTexture: tex },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
|
||||
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
|
||||
<EditablePropertyRow label={t('properties.width')} value={String(item.width)} unit="m" onCommit={handleWidthChange} />
|
||||
|
||||
@@ -85,6 +85,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
state.room.wallHeight !== saved.room.wallHeight ||
|
||||
state.room.plinthHeight !== saved.room.plinthHeight ||
|
||||
state.room.plinthThickness !== saved.room.plinthThickness ||
|
||||
state.room.stretchCeilingOffset !== saved.room.stretchCeilingOffset ||
|
||||
state.room.outletWidth !== saved.room.outletWidth ||
|
||||
state.room.outletHeight !== saved.room.outletHeight ||
|
||||
state.room.name !== saved.room.name;
|
||||
@@ -100,6 +101,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
state.room.wallHeight,
|
||||
state.room.plinthHeight,
|
||||
state.room.plinthThickness,
|
||||
state.room.stretchCeilingOffset,
|
||||
state.room.outletWidth,
|
||||
state.room.outletHeight,
|
||||
state.room.name,
|
||||
@@ -277,6 +279,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
wallHeight: state.room.wallHeight,
|
||||
plinthHeight: state.room.plinthHeight,
|
||||
plinthThickness: state.room.plinthThickness,
|
||||
stretchCeilingOffset: state.room.stretchCeilingOffset,
|
||||
outletWidth: state.room.outletWidth,
|
||||
outletHeight: state.room.outletHeight,
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ function createInitialState(room: RoomFull): EditorState {
|
||||
gridVisible: true,
|
||||
snapEnabled: true,
|
||||
snapGranularity: DEFAULT_GRID_SIZE,
|
||||
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true },
|
||||
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true, stretchCeiling: true },
|
||||
selectedElectricalIndex: null,
|
||||
selectedFurnitureIndex: null,
|
||||
annotations: room.annotations ?? [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Group, Line, Text, Rect } from 'react-konva';
|
||||
import type { Point } from '@house-plan-maker/shared';
|
||||
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||
|
||||
interface GridLayerProps {
|
||||
readonly zoom: number;
|
||||
@@ -10,15 +11,6 @@ interface GridLayerProps {
|
||||
readonly gridSize: number;
|
||||
readonly visible: boolean;
|
||||
}
|
||||
|
||||
/** Color for thin grid lines. */
|
||||
const GRID_LINE_COLOR = '#e0e0e0';
|
||||
/** Color for major grid lines (every 1m). */
|
||||
const MAJOR_GRID_LINE_COLOR = '#c0c0c0';
|
||||
/** Color for ruler background. */
|
||||
const RULER_BG_COLOR = '#f5f5f5';
|
||||
/** Color for ruler text and ticks. */
|
||||
const RULER_TEXT_COLOR = '#666';
|
||||
/** Ruler size in pixels. */
|
||||
const RULER_SIZE = 24;
|
||||
|
||||
@@ -30,6 +22,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
gridSize,
|
||||
visible,
|
||||
}: GridLayerProps) {
|
||||
const colors = useCanvasColors();
|
||||
const gridLines = useMemo(() => {
|
||||
if (!visible) return { lines: [], majorLines: [] };
|
||||
|
||||
@@ -136,7 +129,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
<Line
|
||||
key={`g-${i}`}
|
||||
points={line.points}
|
||||
stroke={GRID_LINE_COLOR}
|
||||
stroke={colors.gridLine}
|
||||
strokeWidth={0.5}
|
||||
listening={false}
|
||||
/>
|
||||
@@ -146,7 +139,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
<Line
|
||||
key={`gm-${i}`}
|
||||
points={line.points}
|
||||
stroke={MAJOR_GRID_LINE_COLOR}
|
||||
stroke={colors.gridLineMajor}
|
||||
strokeWidth={1}
|
||||
listening={false}
|
||||
/>
|
||||
@@ -158,7 +151,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
y={0}
|
||||
width={stageWidth}
|
||||
height={RULER_SIZE}
|
||||
fill={RULER_BG_COLOR}
|
||||
fill={colors.rulerBg}
|
||||
listening={false}
|
||||
/>
|
||||
{rulerMarks.hMarks.map((mark, i) => (
|
||||
@@ -168,7 +161,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
y={4}
|
||||
text={mark.label}
|
||||
fontSize={9}
|
||||
fill={RULER_TEXT_COLOR}
|
||||
fill={colors.rulerText}
|
||||
listening={false}
|
||||
/>
|
||||
))}
|
||||
@@ -176,7 +169,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
<Line
|
||||
key={`rht-${i}`}
|
||||
points={[mark.screenX, RULER_SIZE - 6, mark.screenX, RULER_SIZE]}
|
||||
stroke={RULER_TEXT_COLOR}
|
||||
stroke={colors.rulerText}
|
||||
strokeWidth={1}
|
||||
listening={false}
|
||||
/>
|
||||
@@ -188,7 +181,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
y={0}
|
||||
width={RULER_SIZE}
|
||||
height={stageHeight}
|
||||
fill={RULER_BG_COLOR}
|
||||
fill={colors.rulerBg}
|
||||
listening={false}
|
||||
/>
|
||||
{rulerMarks.vMarks.map((mark, i) => (
|
||||
@@ -198,7 +191,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
y={mark.screenY - 4}
|
||||
text={mark.label}
|
||||
fontSize={9}
|
||||
fill={RULER_TEXT_COLOR}
|
||||
fill={colors.rulerText}
|
||||
listening={false}
|
||||
/>
|
||||
))}
|
||||
@@ -206,7 +199,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
<Line
|
||||
key={`rvt-${i}`}
|
||||
points={[RULER_SIZE - 6, mark.screenY, RULER_SIZE, mark.screenY]}
|
||||
stroke={RULER_TEXT_COLOR}
|
||||
stroke={colors.rulerText}
|
||||
strokeWidth={1}
|
||||
listening={false}
|
||||
/>
|
||||
@@ -218,7 +211,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
y={0}
|
||||
width={RULER_SIZE}
|
||||
height={RULER_SIZE}
|
||||
fill={RULER_BG_COLOR}
|
||||
fill={colors.rulerBg}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Group, Rect, Line, Text } from 'react-konva';
|
||||
import type { ProjectedOpening } from '../utils/projectionMapping';
|
||||
import { projectionToPixel } from '../utils/projectionMapping';
|
||||
import type { DoorOpenDirection } from '@house-plan-maker/shared';
|
||||
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||
|
||||
interface ProjectionDoorProps {
|
||||
readonly projected: ProjectedOpening;
|
||||
@@ -67,6 +68,7 @@ export function ProjectionDoor({
|
||||
onClick,
|
||||
onDragStart,
|
||||
}: ProjectionDoorProps) {
|
||||
const colors = useCanvasColors();
|
||||
const { rect, opening } = projected;
|
||||
const openDirection: DoorOpenDirection = opening.openDirection ?? 'LEFT';
|
||||
|
||||
@@ -102,7 +104,7 @@ export function ProjectionDoor({
|
||||
y={topLeft.y - 2}
|
||||
width={pxWidth + 4}
|
||||
height={pxHeight + 4}
|
||||
stroke="#2563eb"
|
||||
stroke={colors.selectedStroke}
|
||||
strokeWidth={1}
|
||||
dash={[3, 3]}
|
||||
fill="transparent"
|
||||
@@ -114,20 +116,20 @@ export function ProjectionDoor({
|
||||
y={topLeft.y}
|
||||
width={pxWidth}
|
||||
height={pxHeight}
|
||||
fill="#ffffff"
|
||||
stroke={isSelected ? '#2563eb' : '#64748b'}
|
||||
fill={colors.canvasBg}
|
||||
stroke={isSelected ? colors.selectedStroke : colors.electricalStroke}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
/>
|
||||
{/* Door leaf line (vertical line showing hinge side) */}
|
||||
<Line
|
||||
points={leafPoints}
|
||||
stroke="#94a3b8"
|
||||
stroke={colors.dimensionLine}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* Door swing indicator arc */}
|
||||
<Line
|
||||
points={arcPoints}
|
||||
stroke="#94a3b8"
|
||||
stroke={colors.dimensionLine}
|
||||
strokeWidth={1}
|
||||
dash={[4, 3]}
|
||||
/>
|
||||
@@ -139,7 +141,7 @@ export function ProjectionDoor({
|
||||
text="D"
|
||||
align="center"
|
||||
fontSize={11}
|
||||
fill="#64748b"
|
||||
fill={colors.dimensionText}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Group, Circle, Line, Rect, Text } from 'react-konva';
|
||||
import { Group, Circle, Line, Rect, Text, Arc } from 'react-konva';
|
||||
import type { ProjectedElectrical } from '../utils/projectionMapping';
|
||||
import { projectionToPixel } from '../utils/projectionMapping';
|
||||
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||
import { getWallLightStyle, getOutletDirection } from '../symbols/electrical';
|
||||
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||
|
||||
interface ProjectionElectricalProps {
|
||||
readonly projected: ProjectedElectrical;
|
||||
@@ -37,6 +39,7 @@ export function ProjectionElectrical({
|
||||
onClick,
|
||||
onDragStart,
|
||||
}: ProjectionElectricalProps) {
|
||||
const colors = useCanvasColors();
|
||||
const { position, item } = projected;
|
||||
|
||||
const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor;
|
||||
@@ -50,8 +53,8 @@ export function ProjectionElectrical({
|
||||
padding,
|
||||
);
|
||||
|
||||
const strokeColor = isSelected ? '#2563eb' : '#64748b';
|
||||
const fillColor = isSelected ? '#dbeafe' : '#f1f5f9';
|
||||
const strokeColor = isSelected ? colors.selectedStroke : colors.electricalStroke;
|
||||
const fillColor = isSelected ? colors.selectedFill : colors.electricalFill;
|
||||
const half = SYMBOL_SIZE / 2;
|
||||
|
||||
return (
|
||||
@@ -71,7 +74,7 @@ export function ProjectionElectrical({
|
||||
y={center.y - half - 2}
|
||||
width={SYMBOL_SIZE + 4}
|
||||
height={SYMBOL_SIZE + 4}
|
||||
stroke="#2563eb"
|
||||
stroke={colors.selectedStroke}
|
||||
strokeWidth={1}
|
||||
dash={[3, 3]}
|
||||
fill="transparent"
|
||||
@@ -79,6 +82,8 @@ export function ProjectionElectrical({
|
||||
)}
|
||||
{item.type === 'OUTLET' && (() => {
|
||||
const safeCount = Math.max(1, Math.round(item.count));
|
||||
const direction = getOutletDirection(item.metadata);
|
||||
const isVertical = direction === 'vertical';
|
||||
// Convert physical outlet dims to projection-pixel dims.
|
||||
const wPx = outletWidth * scale;
|
||||
const hPx = outletHeight * scale;
|
||||
@@ -100,15 +105,17 @@ export function ProjectionElectrical({
|
||||
? 'left'
|
||||
: 'middle'
|
||||
: anchor.horizontal;
|
||||
const totalW = safeCount * wPx;
|
||||
// Bounding box of the full outlet group depends on layout direction.
|
||||
const totalW = isVertical ? wPx : safeCount * wPx;
|
||||
const totalH = isVertical ? safeCount * hPx : hPx;
|
||||
const offX =
|
||||
mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0;
|
||||
const offY =
|
||||
anchor.vertical === 'top' ? hPx / 2 : anchor.vertical === 'bottom' ? -hPx / 2 : 0;
|
||||
anchor.vertical === 'top' ? totalH / 2 : anchor.vertical === 'bottom' ? -totalH / 2 : 0;
|
||||
const cx = center.x + offX;
|
||||
const cy = center.y + offY;
|
||||
const left = cx - totalW / 2;
|
||||
const top = cy - hPx / 2;
|
||||
const groupLeft = cx - totalW / 2;
|
||||
const groupTop = cy - totalH / 2;
|
||||
const cellMin = Math.min(wPx, hPx);
|
||||
const faceR = cellMin * 0.32;
|
||||
const prongL = cellMin * 0.18;
|
||||
@@ -116,14 +123,15 @@ export function ProjectionElectrical({
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: safeCount }).map((_, i) => {
|
||||
const cellLeft = left + i * wPx;
|
||||
const cellLeft = isVertical ? groupLeft : groupLeft + i * wPx;
|
||||
const cellTop = isVertical ? groupTop + i * hPx : groupTop;
|
||||
const cellCx = cellLeft + wPx / 2;
|
||||
const cellCy = top + hPx / 2;
|
||||
const cellCy = cellTop + hPx / 2;
|
||||
return (
|
||||
<Group key={i}>
|
||||
<Rect
|
||||
x={cellLeft}
|
||||
y={top}
|
||||
y={cellTop}
|
||||
width={wPx}
|
||||
height={hPx}
|
||||
cornerRadius={Math.max(1, cellMin * 0.12)}
|
||||
@@ -178,9 +186,83 @@ export function ProjectionElectrical({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'LIGHT_WALL' && (
|
||||
{item.type === 'LIGHT_WALL' && (() => {
|
||||
const wallLightStyle = getWallLightStyle(item.metadata);
|
||||
if (wallLightStyle === 'pendant-globe') {
|
||||
// Pendant globe: gooseneck line + hanging cord + circle globe
|
||||
const armTop = center.y - half * 1.8;
|
||||
const cordBottom = center.y + half * 0.6;
|
||||
return (
|
||||
<>
|
||||
{/* Wall mount dot */}
|
||||
<Circle x={center.x} y={center.y} radius={3} fill="#b8860b" stroke={strokeColor} strokeWidth={1} />
|
||||
{/* Gooseneck arm going up */}
|
||||
<Line points={[center.x, center.y, center.x, armTop]} stroke="#b8860b" strokeWidth={1.5} />
|
||||
{/* Arm curving outward */}
|
||||
<Line points={[center.x, armTop, center.x + half * 0.8, armTop]} stroke="#b8860b" strokeWidth={1.5} />
|
||||
{/* Cord hanging down */}
|
||||
<Line points={[center.x + half * 0.8, armTop, center.x + half * 0.8, cordBottom]} stroke="#d4c090" strokeWidth={1} />
|
||||
{/* Glass globe */}
|
||||
<Circle
|
||||
x={center.x + half * 0.8}
|
||||
y={cordBottom + half * 0.5}
|
||||
radius={half * 0.7}
|
||||
fill="rgba(254, 249, 195, 0.4)"
|
||||
stroke="#eab308"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (wallLightStyle === 'sconce-up') {
|
||||
// Sconce up: trapezoid pointing up
|
||||
return (
|
||||
<>
|
||||
<Line
|
||||
points={[
|
||||
center.x - half * 0.8, center.y + half * 0.5,
|
||||
center.x - half * 0.4, center.y - half,
|
||||
center.x + half * 0.4, center.y - half,
|
||||
center.x + half * 0.8, center.y + half * 0.5,
|
||||
]}
|
||||
closed
|
||||
fill="#fef9c3"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{/* Light rays upward */}
|
||||
<Line points={[center.x, center.y - half, center.x, center.y - half * 1.8]} stroke="#eab308" strokeWidth={1} />
|
||||
<Line points={[center.x - half * 0.3, center.y - half, center.x - half * 0.5, center.y - half * 1.6]} stroke="#eab308" strokeWidth={1} />
|
||||
<Line points={[center.x + half * 0.3, center.y - half, center.x + half * 0.5, center.y - half * 1.6]} stroke="#eab308" strokeWidth={1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (wallLightStyle === 'sconce-down') {
|
||||
// Sconce down: trapezoid pointing down
|
||||
return (
|
||||
<>
|
||||
<Line
|
||||
points={[
|
||||
center.x - half * 0.8, center.y - half * 0.5,
|
||||
center.x - half * 0.4, center.y + half,
|
||||
center.x + half * 0.4, center.y + half,
|
||||
center.x + half * 0.8, center.y - half * 0.5,
|
||||
]}
|
||||
closed
|
||||
fill="#fef9c3"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{/* Light rays downward */}
|
||||
<Line points={[center.x, center.y + half, center.x, center.y + half * 1.8]} stroke="#eab308" strokeWidth={1} />
|
||||
<Line points={[center.x - half * 0.3, center.y + half, center.x - half * 0.5, center.y + half * 1.6]} stroke="#eab308" strokeWidth={1} />
|
||||
<Line points={[center.x + half * 0.3, center.y + half, center.x + half * 0.5, center.y + half * 1.6]} stroke="#eab308" strokeWidth={1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Classic: original circle with light rays
|
||||
return (
|
||||
<>
|
||||
{/* Wall light: semicircle shape */}
|
||||
<Circle
|
||||
x={center.x}
|
||||
y={center.y}
|
||||
@@ -206,7 +288,8 @@ export function ProjectionElectrical({
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
{/* Fallback for other wall-mounted types */}
|
||||
{item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && (
|
||||
<Rect
|
||||
@@ -236,7 +319,7 @@ export function ProjectionElectrical({
|
||||
}
|
||||
align="center"
|
||||
fontSize={8}
|
||||
fill="#94a3b8"
|
||||
fill={colors.electricalLabel}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Group, Rect, Text } from 'react-konva';
|
||||
import type { ProjectedFurniture } from '../utils/projectionMapping';
|
||||
import { projectionToPixel } from '../utils/projectionMapping';
|
||||
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||
|
||||
interface ProjectionFurnitureProps {
|
||||
readonly projected: ProjectedFurniture;
|
||||
@@ -9,7 +10,8 @@ interface ProjectionFurnitureProps {
|
||||
readonly padding: number;
|
||||
readonly isSelected: boolean;
|
||||
readonly globalOpacity?: number;
|
||||
readonly onClick: () => void;
|
||||
/** Omit to make the item non-interactive (events fall through). */
|
||||
readonly onClick?: () => void;
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
@@ -31,6 +33,7 @@ export function ProjectionFurniture({
|
||||
globalOpacity = 1,
|
||||
onClick,
|
||||
}: ProjectionFurnitureProps) {
|
||||
const colors = useCanvasColors();
|
||||
const { rect, item } = projected;
|
||||
|
||||
const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
|
||||
@@ -40,15 +43,16 @@ export function ProjectionFurniture({
|
||||
const color = TYPE_COLORS[item.type] ?? '#a0845c';
|
||||
|
||||
const itemOpacity = (item.opacity ?? 1) * globalOpacity;
|
||||
const interactive = onClick !== undefined;
|
||||
return (
|
||||
<Group onClick={onClick} opacity={itemOpacity}>
|
||||
<Group onClick={onClick} opacity={itemOpacity} listening={interactive}>
|
||||
<Rect
|
||||
x={topLeft.x}
|
||||
y={topLeft.y}
|
||||
width={pxWidth}
|
||||
height={pxHeight}
|
||||
fill={isSelected ? '#dbeafe' : color}
|
||||
stroke={isSelected ? '#2563eb' : '#6b5b3a'}
|
||||
fill={isSelected ? colors.selectedFill : color}
|
||||
stroke={isSelected ? colors.selectedStroke : colors.furnitureStroke}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
opacity={0.7}
|
||||
/>
|
||||
@@ -60,7 +64,7 @@ export function ProjectionFurniture({
|
||||
text={item.label ?? item.type}
|
||||
align="center"
|
||||
fontSize={9}
|
||||
fill={isSelected ? '#1e40af' : '#3b2f1e'}
|
||||
fill={isSelected ? colors.selectedText : colors.furnitureText}
|
||||
ellipsis
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Group, Rect, Line, Text } from 'react-konva';
|
||||
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||
import type {
|
||||
ProjectedOpening,
|
||||
ProjectedElectrical,
|
||||
@@ -7,6 +8,7 @@ import type {
|
||||
} from '../utils/projectionMapping';
|
||||
import { projectionToPixel } from '../utils/projectionMapping';
|
||||
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||
import { getOutletInvertCoordX, getOutletInvertCoordY } from '../symbols/electrical';
|
||||
|
||||
interface ProjectionMeasurementsProps {
|
||||
readonly projectedOpenings: readonly ProjectedOpening[];
|
||||
@@ -25,7 +27,7 @@ interface ProjectionMeasurementsProps {
|
||||
|
||||
/** Dimension line with arrows and text. */
|
||||
function DimensionLine({
|
||||
x1, y1, x2, y2, label, offset, horizontal,
|
||||
x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor,
|
||||
}: {
|
||||
readonly x1: number;
|
||||
readonly y1: number;
|
||||
@@ -34,6 +36,8 @@ function DimensionLine({
|
||||
readonly label: string;
|
||||
readonly offset: number;
|
||||
readonly horizontal: boolean;
|
||||
readonly lineColor: string;
|
||||
readonly textColor: string;
|
||||
}) {
|
||||
const arrowSize = 4;
|
||||
|
||||
@@ -43,19 +47,19 @@ function DimensionLine({
|
||||
return (
|
||||
<Group>
|
||||
{/* Extension lines */}
|
||||
<Line points={[x1, y1, x1, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
|
||||
<Line points={[x2, y2, x2, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
|
||||
<Line points={[x1, y1, x1, lineY]} stroke={lineColor} strokeWidth={0.5} />
|
||||
<Line points={[x2, y2, x2, lineY]} stroke={lineColor} strokeWidth={0.5} />
|
||||
{/* Main line */}
|
||||
<Line points={[x1, lineY, x2, lineY]} stroke="#94a3b8" strokeWidth={0.75} />
|
||||
<Line points={[x1, lineY, x2, lineY]} stroke={lineColor} strokeWidth={0.75} />
|
||||
{/* Arrows */}
|
||||
<Line
|
||||
points={[x1, lineY, x1 + arrowSize, lineY - arrowSize / 2, x1 + arrowSize, lineY + arrowSize / 2]}
|
||||
fill="#94a3b8"
|
||||
fill={lineColor}
|
||||
closed
|
||||
/>
|
||||
<Line
|
||||
points={[x2, lineY, x2 - arrowSize, lineY - arrowSize / 2, x2 - arrowSize, lineY + arrowSize / 2]}
|
||||
fill="#94a3b8"
|
||||
fill={lineColor}
|
||||
closed
|
||||
/>
|
||||
{/* Label */}
|
||||
@@ -66,7 +70,7 @@ function DimensionLine({
|
||||
text={label}
|
||||
align="center"
|
||||
fontSize={9}
|
||||
fill="#64748b"
|
||||
fill={textColor}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
@@ -77,17 +81,17 @@ function DimensionLine({
|
||||
const midY = (y1 + y2) / 2;
|
||||
return (
|
||||
<Group>
|
||||
<Line points={[x1, y1, lineX, y1]} stroke="#94a3b8" strokeWidth={0.5} />
|
||||
<Line points={[x1, y2, lineX, y2]} stroke="#94a3b8" strokeWidth={0.5} />
|
||||
<Line points={[lineX, y1, lineX, y2]} stroke="#94a3b8" strokeWidth={0.75} />
|
||||
<Line points={[x1, y1, lineX, y1]} stroke={lineColor} strokeWidth={0.5} />
|
||||
<Line points={[x1, y2, lineX, y2]} stroke={lineColor} strokeWidth={0.5} />
|
||||
<Line points={[lineX, y1, lineX, y2]} stroke={lineColor} strokeWidth={0.75} />
|
||||
<Line
|
||||
points={[lineX, y1, lineX - arrowSize / 2, y1 + arrowSize, lineX + arrowSize / 2, y1 + arrowSize]}
|
||||
fill="#94a3b8"
|
||||
fill={lineColor}
|
||||
closed
|
||||
/>
|
||||
<Line
|
||||
points={[lineX, y2, lineX - arrowSize / 2, y2 - arrowSize, lineX + arrowSize / 2, y2 - arrowSize]}
|
||||
fill="#94a3b8"
|
||||
fill={lineColor}
|
||||
closed
|
||||
/>
|
||||
<Text
|
||||
@@ -95,7 +99,7 @@ function DimensionLine({
|
||||
y={midY - 5}
|
||||
text={label}
|
||||
fontSize={9}
|
||||
fill="#64748b"
|
||||
fill={textColor}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
@@ -118,6 +122,7 @@ export function ProjectionMeasurements({
|
||||
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||
showWallDimensions = true,
|
||||
}: ProjectionMeasurementsProps) {
|
||||
const colors = useCanvasColors();
|
||||
const elements: ReactNode[] = [];
|
||||
|
||||
if (showWallDimensions) {
|
||||
@@ -134,6 +139,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(wallLen)}
|
||||
offset={18}
|
||||
horizontal
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -150,6 +157,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(wallHeight)}
|
||||
offset={18}
|
||||
horizontal={false}
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -171,6 +180,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.height)}
|
||||
offset={-14}
|
||||
horizontal={false}
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -187,6 +198,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.y)}
|
||||
offset={-14}
|
||||
horizontal={false}
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -203,6 +216,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.width)}
|
||||
offset={-12}
|
||||
horizontal
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -229,7 +244,14 @@ export function ProjectionMeasurements({
|
||||
halfWidthPx = (safeCount * outletWidth * scale) / 2;
|
||||
}
|
||||
|
||||
const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.toFixed(2)})`;
|
||||
// Outlets can opt into inverted coordinate read-outs per axis — useful
|
||||
// when the user measures distances from the opposite wall edge or from
|
||||
// the ceiling. Display-only: the stored position is untouched.
|
||||
const invertX = pe.item.type === 'OUTLET' && getOutletInvertCoordX(pe.item.metadata);
|
||||
const invertY = pe.item.type === 'OUTLET' && getOutletInvertCoordY(pe.item.metadata);
|
||||
const displayX = invertX ? wallLen - pe.position.alongWall : pe.position.alongWall;
|
||||
const displayY = invertY ? wallHeight - pe.elevation : pe.elevation;
|
||||
const coordLabel = `(${displayX.toFixed(2)}; ${displayY.toFixed(2)})`;
|
||||
const labelX = center.x + halfWidthPx + 6;
|
||||
const labelY = center.y - 6;
|
||||
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
|
||||
@@ -241,7 +263,7 @@ export function ProjectionMeasurements({
|
||||
y={labelY - 1}
|
||||
width={labelWidth}
|
||||
height={12}
|
||||
fill="rgba(255, 255, 255, 0.85)"
|
||||
fill={colors.coordLabelBg}
|
||||
cornerRadius={2}
|
||||
listening={false}
|
||||
/>
|
||||
@@ -250,7 +272,7 @@ export function ProjectionMeasurements({
|
||||
y={labelY}
|
||||
text={coordLabel}
|
||||
fontSize={9}
|
||||
fill="#475569"
|
||||
fill={colors.coordLabelText}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>,
|
||||
@@ -274,7 +296,7 @@ export function ProjectionMeasurements({
|
||||
y={openingCenter.y - 22}
|
||||
text={formatM(rect.x + rect.width / 2)}
|
||||
fontSize={8}
|
||||
fill="#94a3b8"
|
||||
fill={colors.openingLabel}
|
||||
align="center"
|
||||
width={32}
|
||||
/>,
|
||||
@@ -313,6 +335,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.width)}
|
||||
offset={32}
|
||||
horizontal
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
if (rect.x > 0.001) {
|
||||
@@ -327,6 +351,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.x)}
|
||||
offset={46}
|
||||
horizontal
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -342,6 +368,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.width)}
|
||||
offset={14}
|
||||
horizontal
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
// Inline start-offset (distance from wall start to the left edge),
|
||||
@@ -359,6 +387,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.x)}
|
||||
offset={-6}
|
||||
horizontal
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -381,6 +411,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.height)}
|
||||
offset={-32}
|
||||
horizontal={false}
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
if (rect.y > 0.001) {
|
||||
@@ -395,6 +427,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.y)}
|
||||
offset={-46}
|
||||
horizontal={false}
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -410,6 +444,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.height)}
|
||||
offset={-14}
|
||||
horizontal={false}
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
// Inline elevation (distance from floor to the bottom of the item),
|
||||
@@ -427,6 +463,8 @@ export function ProjectionMeasurements({
|
||||
label={formatM(rect.y)}
|
||||
offset={6}
|
||||
horizontal={false}
|
||||
lineColor={colors.dimensionLine}
|
||||
textColor={colors.dimensionText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
||||
globalFurnitureOpacity,
|
||||
wallHeight: room.wallHeight,
|
||||
plinthHeight: room.plinthHeight,
|
||||
stretchCeilingOffset: layerVisibility.stretchCeiling ? room.stretchCeilingOffset : 0,
|
||||
outletWidth: room.outletWidth,
|
||||
outletHeight: room.outletHeight,
|
||||
selectedIds,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Group, Rect, Line } from 'react-konva';
|
||||
import type { ProjectedOpening } from '../utils/projectionMapping';
|
||||
import { projectionToPixel } from '../utils/projectionMapping';
|
||||
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||
|
||||
interface ProjectionWindowProps {
|
||||
readonly projected: ProjectedOpening;
|
||||
@@ -26,6 +27,7 @@ export function ProjectionWindow({
|
||||
onClick,
|
||||
onDragStart,
|
||||
}: ProjectionWindowProps) {
|
||||
const colors = useCanvasColors();
|
||||
const { rect, opening } = projected;
|
||||
|
||||
const displayX = isDragging && dragAlongWall != null
|
||||
@@ -54,7 +56,7 @@ export function ProjectionWindow({
|
||||
y={topLeft.y - 2}
|
||||
width={pxWidth + 4}
|
||||
height={pxHeight + 4}
|
||||
stroke="#2563eb"
|
||||
stroke={colors.selectedStroke}
|
||||
strokeWidth={1}
|
||||
dash={[3, 3]}
|
||||
fill="transparent"
|
||||
@@ -67,7 +69,7 @@ export function ProjectionWindow({
|
||||
width={pxWidth}
|
||||
height={pxHeight}
|
||||
fill="#dbeafe"
|
||||
stroke={isSelected ? '#2563eb' : '#3b82f6'}
|
||||
stroke={isSelected ? colors.selectedStroke : '#3b82f6'}
|
||||
strokeWidth={isSelected ? 2.5 : 1.5}
|
||||
/>
|
||||
{/* Glass pane (inner rectangle) */}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ProjectionElectrical } from './ProjectionElectrical';
|
||||
import { ProjectionFurniture } from './ProjectionFurniture';
|
||||
import { ProjectionMeasurements } from './ProjectionMeasurements';
|
||||
import type { EditorToolType } from '../types';
|
||||
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||
|
||||
interface WallProjectionViewProps {
|
||||
readonly wall: Wall;
|
||||
@@ -32,6 +33,8 @@ interface WallProjectionViewProps {
|
||||
readonly showMeasurements?: boolean;
|
||||
readonly wallHeight: number;
|
||||
readonly plinthHeight: number;
|
||||
/** Distance (m) the stretch ceiling hangs below the structural ceiling. */
|
||||
readonly stretchCeilingOffset?: number;
|
||||
readonly outletWidth?: number;
|
||||
readonly outletHeight?: number;
|
||||
readonly selectedIds: ReadonlySet<string>;
|
||||
@@ -51,8 +54,7 @@ interface WallProjectionViewProps {
|
||||
|
||||
const PADDING = 40;
|
||||
const PLINTH_COLOR = '#8b7355';
|
||||
const WALL_FILL = '#f8fafc';
|
||||
const WALL_STROKE = '#334155';
|
||||
// WALL_FILL and WALL_STROKE now come from useCanvasColors().
|
||||
const FLOOR_COLOR = '#94a3b8';
|
||||
const CEILING_COLOR = '#94a3b8';
|
||||
|
||||
@@ -88,6 +90,7 @@ export function WallProjectionView({
|
||||
showMeasurements = true,
|
||||
wallHeight,
|
||||
plinthHeight,
|
||||
stretchCeilingOffset = 0,
|
||||
outletWidth,
|
||||
outletHeight,
|
||||
selectedIds,
|
||||
@@ -105,6 +108,7 @@ export function WallProjectionView({
|
||||
selectedElectricalType,
|
||||
}: WallProjectionViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useCanvasColors();
|
||||
const stageRef = useRef<Konva.Stage | null>(null);
|
||||
|
||||
// Expose stage ref to parent for export
|
||||
@@ -549,7 +553,7 @@ export function WallProjectionView({
|
||||
y={0}
|
||||
width={width}
|
||||
height={height}
|
||||
fill={isHighlighted ? '#eff6ff' : '#ffffff'}
|
||||
fill={isHighlighted ? colors.canvasBgHighlight : colors.canvasBg}
|
||||
/>
|
||||
|
||||
{/* Pan group: all content shifts with viewPan */}
|
||||
@@ -574,7 +578,7 @@ export function WallProjectionView({
|
||||
<Line
|
||||
key={`gv-${w}`}
|
||||
points={[px, PADDING, px, PADDING + wallHeight * effectiveScale]}
|
||||
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
|
||||
stroke={isMajor ? colors.gridLineMajor : colors.gridLine}
|
||||
strokeWidth={isMajor ? 0.8 : 0.4}
|
||||
listening={false}
|
||||
/>,
|
||||
@@ -591,7 +595,7 @@ export function WallProjectionView({
|
||||
<Line
|
||||
key={`gh-${h}`}
|
||||
points={[PADDING, py, PADDING + wallLen * effectiveScale, py]}
|
||||
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
|
||||
stroke={isMajor ? colors.gridLineMajor : colors.gridLine}
|
||||
strokeWidth={isMajor ? 0.8 : 0.4}
|
||||
listening={false}
|
||||
/>,
|
||||
@@ -612,7 +616,7 @@ export function WallProjectionView({
|
||||
<Line
|
||||
key={`rt-${w}`}
|
||||
points={[px, py, px, py + 6]}
|
||||
stroke="#94a3b8"
|
||||
stroke={colors.rulerText}
|
||||
strokeWidth={0.8}
|
||||
listening={false}
|
||||
/>,
|
||||
@@ -626,7 +630,7 @@ export function WallProjectionView({
|
||||
text={`${w}`}
|
||||
align="center"
|
||||
fontSize={8}
|
||||
fill="#94a3b8"
|
||||
fill={colors.rulerText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -643,7 +647,7 @@ export function WallProjectionView({
|
||||
<Line
|
||||
key={`ht-${h}`}
|
||||
points={[PADDING - 6, py, PADDING, py]}
|
||||
stroke="#94a3b8"
|
||||
stroke={colors.rulerText}
|
||||
strokeWidth={0.8}
|
||||
listening={false}
|
||||
/>,
|
||||
@@ -657,7 +661,7 @@ export function WallProjectionView({
|
||||
text={`${h}`}
|
||||
align="right"
|
||||
fontSize={8}
|
||||
fill="#94a3b8"
|
||||
fill={colors.rulerText}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -671,8 +675,8 @@ export function WallProjectionView({
|
||||
y={topLeft.y}
|
||||
width={(topRight.x - topLeft.x)}
|
||||
height={(bottomLeft.y - topLeft.y)}
|
||||
fill={WALL_FILL}
|
||||
stroke={isHighlighted ? '#2563eb' : WALL_STROKE}
|
||||
fill={colors.wallFill}
|
||||
stroke={isHighlighted ? colors.selectedStroke : colors.wallStroke}
|
||||
strokeWidth={isHighlighted ? 2 : 1.5}
|
||||
onClick={handleWallBgClick}
|
||||
/>
|
||||
@@ -685,6 +689,46 @@ export function WallProjectionView({
|
||||
dash={[6, 4]}
|
||||
/>
|
||||
|
||||
{/* Stretch ceiling line — drawn *below* the structural ceiling at
|
||||
`wallHeight - stretchCeilingOffset`. A tinted band fills the
|
||||
dropped zone so the user can see how much vertical space is
|
||||
lost. Only rendered when the offset is non-zero. */}
|
||||
{stretchCeilingOffset > 0 && (() => {
|
||||
const stretchY = Math.max(0, wallHeight - stretchCeilingOffset);
|
||||
const stretchLeft = toPixel(0, stretchY);
|
||||
const stretchRight = toPixel(wallLen, stretchY);
|
||||
return (
|
||||
<>
|
||||
{/* Tinted fill for the dropped zone between the structural
|
||||
ceiling and the stretch ceiling plane. */}
|
||||
<Rect
|
||||
x={topLeft.x}
|
||||
y={topLeft.y}
|
||||
width={topRight.x - topLeft.x}
|
||||
height={stretchLeft.y - topLeft.y}
|
||||
fill="#60a5fa"
|
||||
opacity={0.18}
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={[stretchLeft.x, stretchLeft.y, stretchRight.x, stretchRight.y]}
|
||||
stroke="#2563eb"
|
||||
strokeWidth={1.25}
|
||||
dash={[4, 3]}
|
||||
listening={false}
|
||||
/>
|
||||
<Text
|
||||
x={stretchLeft.x + 4}
|
||||
y={stretchLeft.y - 12}
|
||||
text={`${t('properties.stretchCeilingOffset')}: ${(stretchCeilingOffset * 100).toFixed(1)}cm`}
|
||||
fontSize={9}
|
||||
fill="#2563eb"
|
||||
listening={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Floor line */}
|
||||
<Line
|
||||
points={[bottomLeft.x, bottomLeft.y, bottomRight.x, bottomRight.y]}
|
||||
@@ -754,8 +798,13 @@ export function WallProjectionView({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Furniture items (rendered first so electrical overlays them) */}
|
||||
{projectedFurniture.map((pf) => (
|
||||
{/* Furniture items (rendered first so electrical overlays them).
|
||||
When global opacity is effectively zero the items are invisible,
|
||||
so don't wire click handlers — clicks should fall through to
|
||||
whatever is underneath (wall background, electrical, …). */}
|
||||
{projectedFurniture.map((pf) => {
|
||||
const furnitureInteractive = globalFurnitureOpacity >= 0.05;
|
||||
return (
|
||||
<ProjectionFurniture
|
||||
key={pf.item.id}
|
||||
projected={pf}
|
||||
@@ -764,9 +813,10 @@ export function WallProjectionView({
|
||||
padding={PADDING}
|
||||
isSelected={selectedIds.has(pf.item.id)}
|
||||
globalOpacity={globalFurnitureOpacity}
|
||||
onClick={() => onSelectElement(pf.item.id)}
|
||||
onClick={furnitureInteractive ? () => onSelectElement(pf.item.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Electrical items (on top of furniture) */}
|
||||
{projectedElectrical.map((pe) => {
|
||||
|
||||
@@ -38,3 +38,53 @@ export function getElectricalVariant(metadata: Record<string, unknown> | null):
|
||||
}
|
||||
return 'single';
|
||||
}
|
||||
|
||||
/** Get the wall light style from an electrical item's metadata. */
|
||||
export function getWallLightStyle(metadata: Record<string, unknown> | null): import('@house-plan-maker/shared').WallLightStyle {
|
||||
if (metadata && typeof metadata['wallLightStyle'] === 'string') {
|
||||
return metadata['wallLightStyle'] as import('@house-plan-maker/shared').WallLightStyle;
|
||||
}
|
||||
return 'classic';
|
||||
}
|
||||
|
||||
/** Get the cord/rope length for pendant-style wall lights (meters). Default 0.3m. */
|
||||
export function getWallLightCordLength(metadata: Record<string, unknown> | null): number {
|
||||
if (metadata && typeof metadata['cordLength'] === 'number') {
|
||||
return metadata['cordLength'] as number;
|
||||
}
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
/** Get the ceiling lamp diameter (meters). Default 0.16m. */
|
||||
export function getCeilingLampSize(metadata: Record<string, unknown> | null): number {
|
||||
if (metadata && typeof metadata['lampSize'] === 'number') {
|
||||
return metadata['lampSize'] as number;
|
||||
}
|
||||
return 0.16;
|
||||
}
|
||||
|
||||
/** Get the outlet direction from metadata. Default is 'horizontal'. */
|
||||
export function getOutletDirection(metadata: Record<string, unknown> | null): import('@house-plan-maker/shared').OutletDirection {
|
||||
if (metadata && metadata['outletDirection'] === 'vertical') {
|
||||
return 'vertical';
|
||||
}
|
||||
return 'horizontal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the along-wall coordinate should be inverted (`wallLength - x`)
|
||||
* when shown in projection labels / read-outs. Display-only: the stored
|
||||
* position is untouched.
|
||||
*/
|
||||
export function getOutletInvertCoordX(metadata: Record<string, unknown> | null): boolean {
|
||||
return metadata?.['invertCoordX'] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the from-floor coordinate should be inverted (`wallHeight - y`)
|
||||
* when shown in projection labels / read-outs. Display-only: the stored
|
||||
* elevation is untouched.
|
||||
*/
|
||||
export function getOutletInvertCoordY(metadata: Record<string, unknown> | null): boolean {
|
||||
return metadata?.['invertCoordY'] === true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { ElectricalItem, ElectricalType, Point, Wall } from '@house-plan-maker/shared';
|
||||
import type { ElectricalItem, ElectricalType, Point, Wall, WallLightStyle } from '@house-plan-maker/shared';
|
||||
import { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry';
|
||||
import { getWallLightStyle, getWallLightCordLength, getCeilingLampSize, getOutletDirection } from '../symbols/electrical';
|
||||
|
||||
interface ElectricalMeshWithHeightProps {
|
||||
readonly item: ElectricalItem;
|
||||
@@ -32,12 +33,16 @@ function outletAnchorOffset(
|
||||
outletWidth: number,
|
||||
outletHeight: number,
|
||||
): { readonly cx: number; readonly cy: number } {
|
||||
const totalWidth = Math.max(1, item.count) * outletWidth;
|
||||
const safeCount = Math.max(1, item.count);
|
||||
const direction = getOutletDirection(item.metadata);
|
||||
const isVertical = direction === 'vertical';
|
||||
const totalWidth = isVertical ? outletWidth : safeCount * outletWidth;
|
||||
const totalHeight = isVertical ? safeCount * outletHeight : outletHeight;
|
||||
const h = item.positionAnchor.horizontal;
|
||||
const v = item.positionAnchor.vertical;
|
||||
const cx = h === 'left' ? totalWidth / 2 : h === 'right' ? -totalWidth / 2 : 0;
|
||||
// Note: in 3D +y is up, so 'top' anchor means the center is BELOW (negative y).
|
||||
const cy = v === 'top' ? -outletHeight / 2 : v === 'bottom' ? outletHeight / 2 : 0;
|
||||
const cy = v === 'top' ? -totalHeight / 2 : v === 'bottom' ? totalHeight / 2 : 0;
|
||||
return { cx, cy };
|
||||
}
|
||||
|
||||
@@ -75,6 +80,7 @@ function OutletMesh({
|
||||
outletHeight,
|
||||
centerX,
|
||||
centerY,
|
||||
isVertical,
|
||||
}: {
|
||||
readonly color: string;
|
||||
readonly count: number;
|
||||
@@ -82,6 +88,7 @@ function OutletMesh({
|
||||
readonly outletHeight: number;
|
||||
readonly centerX: number;
|
||||
readonly centerY: number;
|
||||
readonly isVertical: boolean;
|
||||
}) {
|
||||
const safeCount = Math.max(1, Math.round(count));
|
||||
// Depth into the wall: stays small, just enough to be visible.
|
||||
@@ -89,10 +96,11 @@ function OutletMesh({
|
||||
return (
|
||||
<group position={[centerX, centerY, 0]}>
|
||||
{Array.from({ length: safeCount }).map((_, i) => {
|
||||
// Center index 0..N-1 around 0 along local x.
|
||||
const localX = (i - (safeCount - 1) / 2) * outletWidth;
|
||||
// Center index 0..N-1 around 0 along the layout axis.
|
||||
const localX = isVertical ? 0 : (i - (safeCount - 1) / 2) * outletWidth;
|
||||
const localY = isVertical ? (i - (safeCount - 1) / 2) * outletHeight : 0;
|
||||
return (
|
||||
<mesh key={i} position={[localX, 0, 0]} castShadow>
|
||||
<mesh key={i} position={[localX, localY, 0]} castShadow>
|
||||
<boxGeometry args={[outletWidth * 0.95, outletHeight * 0.95, depth]} />
|
||||
<meshStandardMaterial color={color} roughness={0.3} />
|
||||
</mesh>
|
||||
@@ -129,18 +137,28 @@ function JunctionBoxMesh({ color }: { readonly color: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Ceiling light: disc or sphere hanging from ceiling */
|
||||
function CeilingLightMesh({ color, wallHeight }: { readonly color: string; readonly wallHeight: number }) {
|
||||
/** Ceiling light: disc or sphere hanging from ceiling, scalable by lampSize (diameter in meters). */
|
||||
function CeilingLightMesh({ color, wallHeight, lampSize }: {
|
||||
readonly color: string;
|
||||
readonly wallHeight: number;
|
||||
readonly lampSize: number;
|
||||
}) {
|
||||
// lampSize is the overall diameter; derive component sizes from it.
|
||||
const radius = lampSize / 2;
|
||||
const canopyRadius = radius * 0.625; // proportional canopy
|
||||
const canopyHeight = Math.max(0.015, lampSize * 0.125);
|
||||
const dropDistance = lampSize * 0.75; // how far shade hangs below canopy
|
||||
|
||||
return (
|
||||
<group position={[0, wallHeight - 0.05, 0]}>
|
||||
{/* Canopy */}
|
||||
<mesh castShadow>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.02, 16]} />
|
||||
<cylinderGeometry args={[canopyRadius, canopyRadius, canopyHeight, 16]} />
|
||||
<meshStandardMaterial color="#666666" roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Shade / bulb */}
|
||||
<mesh position={[0, -0.12, 0]}>
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<mesh position={[0, -dropDistance, 0]}>
|
||||
<sphereGeometry args={[radius, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
@@ -152,18 +170,22 @@ function CeilingLightMesh({ color, wallHeight }: { readonly color: string; reado
|
||||
);
|
||||
}
|
||||
|
||||
/** Wall light: half-sphere attached to wall */
|
||||
function WallLightMesh({ color }: { readonly color: string }) {
|
||||
/** Classic wall light: half-sphere attached to wall, scalable by lampSize (diameter in meters). */
|
||||
function WallLightClassic({ color, lampSize }: { readonly color: string; readonly lampSize: number }) {
|
||||
const scale = lampSize / 0.12; // 0.12 = default diameter (2 * 0.06 shade radius)
|
||||
const plateSize = 0.10 * scale;
|
||||
const shadeRadius = 0.06 * scale;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Base plate */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.10, 0.10, 0.02]} />
|
||||
<boxGeometry args={[plateSize, plateSize, 0.02]} />
|
||||
<meshStandardMaterial color="#666666" roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Half sphere shade */}
|
||||
<mesh position={[0, 0, 0.03]}>
|
||||
<sphereGeometry args={[0.06, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||
<sphereGeometry args={[shadeRadius, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
@@ -176,6 +198,183 @@ function WallLightMesh({ color }: { readonly color: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Pendant globe wall light: gooseneck arm with hanging glass globe (adjustable cord and size). */
|
||||
function WallLightPendantGlobe({ color, cordLength, lampSize }: {
|
||||
readonly color: string;
|
||||
readonly cordLength: number;
|
||||
readonly lampSize: number;
|
||||
}) {
|
||||
const armColor = '#b8860b'; // brass/dark gold
|
||||
const cordLen = Math.max(0.05, cordLength);
|
||||
const scale = lampSize / 0.14; // 0.14 = default diameter (2 * 0.07 globe radius)
|
||||
const globeRadius = 0.07 * scale;
|
||||
const armHeight = 0.12 * scale;
|
||||
const armReach = 0.06 * scale;
|
||||
const mountRadius = 0.04 * scale;
|
||||
const armThickness = 0.006 * scale;
|
||||
const canopyTop = 0.02 * scale;
|
||||
const canopyBot = 0.025 * scale;
|
||||
const filamentRadius = 0.015 * scale;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Round wall mount plate */}
|
||||
<mesh castShadow>
|
||||
<cylinderGeometry args={[mountRadius, mountRadius, 0.015, 16]} />
|
||||
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Vertical arm going up */}
|
||||
<mesh position={[0, armHeight / 2, 0]} castShadow>
|
||||
<cylinderGeometry args={[armThickness, armThickness, armHeight, 8]} />
|
||||
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Curved gooseneck top (simplified as a horizontal segment) */}
|
||||
<mesh position={[0, armHeight, armReach / 2]} rotation={[Math.PI / 2, 0, 0]} castShadow>
|
||||
<cylinderGeometry args={[armThickness, armThickness, armReach, 8]} />
|
||||
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Cord / rope hanging down */}
|
||||
<mesh position={[0, armHeight - cordLen / 2, armReach]} castShadow>
|
||||
<cylinderGeometry args={[0.003, 0.003, cordLen, 6]} />
|
||||
<meshStandardMaterial color="#f5f0e0" roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* Globe canopy (brass ring at top of globe) */}
|
||||
<mesh position={[0, armHeight - cordLen, armReach]}>
|
||||
<cylinderGeometry args={[canopyTop, canopyBot, 0.015, 16]} />
|
||||
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Glass globe */}
|
||||
<mesh position={[0, armHeight - cordLen - globeRadius * 0.7, armReach]}>
|
||||
<sphereGeometry args={[globeRadius, 24, 24]} />
|
||||
<meshPhysicalMaterial
|
||||
color="#ffffff"
|
||||
transmission={0.85}
|
||||
roughness={0.05}
|
||||
thickness={0.5}
|
||||
ior={1.5}
|
||||
transparent
|
||||
opacity={0.35}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Bulb filament inside globe */}
|
||||
<mesh position={[0, armHeight - cordLen - globeRadius * 0.7, armReach]}>
|
||||
<sphereGeometry args={[filamentRadius, 8, 8]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.5}
|
||||
roughness={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sconce pointing upward */
|
||||
function WallLightSconceUp({ color, lampSize }: { readonly color: string; readonly lampSize: number }) {
|
||||
const scale = lampSize / 0.10; // 0.10 = default diameter (2 * 0.05 cone radius)
|
||||
const plateW = 0.08 * scale;
|
||||
const plateH = 0.12 * scale;
|
||||
const coneRadius = 0.05 * scale;
|
||||
const coneHeight = 0.10 * scale;
|
||||
const glowRadius = 0.02 * scale;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Base plate */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[plateW, plateH, 0.02]} />
|
||||
<meshStandardMaterial color="#666666" roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Upward cone shade */}
|
||||
<mesh position={[0, coneHeight * 0.4, 0.03]} rotation={[0, 0, 0]}>
|
||||
<coneGeometry args={[coneRadius, coneHeight, 16, 1, true]} />
|
||||
<meshStandardMaterial
|
||||
color="#e0d5c0"
|
||||
roughness={0.5}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Light glow */}
|
||||
<mesh position={[0, coneHeight * 0.6, 0.03]}>
|
||||
<sphereGeometry args={[glowRadius, 8, 8]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.4}
|
||||
roughness={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sconce pointing downward */
|
||||
function WallLightSconceDown({ color, lampSize }: { readonly color: string; readonly lampSize: number }) {
|
||||
const scale = lampSize / 0.10; // 0.10 = default diameter (2 * 0.05 cone radius)
|
||||
const plateW = 0.08 * scale;
|
||||
const plateH = 0.12 * scale;
|
||||
const coneRadius = 0.05 * scale;
|
||||
const coneHeight = 0.10 * scale;
|
||||
const glowRadius = 0.02 * scale;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Base plate */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[plateW, plateH, 0.02]} />
|
||||
<meshStandardMaterial color="#666666" roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Downward cone shade (rotated 180°) */}
|
||||
<mesh position={[0, -coneHeight * 0.4, 0.03]} rotation={[Math.PI, 0, 0]}>
|
||||
<coneGeometry args={[coneRadius, coneHeight, 16, 1, true]} />
|
||||
<meshStandardMaterial
|
||||
color="#e0d5c0"
|
||||
roughness={0.5}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Light glow */}
|
||||
<mesh position={[0, -coneHeight * 0.6, 0.03]}>
|
||||
<sphereGeometry args={[glowRadius, 8, 8]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.4}
|
||||
roughness={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wall light: renders the appropriate style variant */
|
||||
function WallLightMesh({ color, style, cordLength, lampSize }: {
|
||||
readonly color: string;
|
||||
readonly style: WallLightStyle;
|
||||
readonly cordLength: number;
|
||||
readonly lampSize: number;
|
||||
}) {
|
||||
switch (style) {
|
||||
case 'pendant-globe':
|
||||
return <WallLightPendantGlobe color={color} cordLength={cordLength} lampSize={lampSize} />;
|
||||
case 'sconce-up':
|
||||
return <WallLightSconceUp color={color} lampSize={lampSize} />;
|
||||
case 'sconce-down':
|
||||
return <WallLightSconceDown color={color} lampSize={lampSize} />;
|
||||
case 'classic':
|
||||
default:
|
||||
return <WallLightClassic color={color} lampSize={lampSize} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cable route: small orange marker */
|
||||
function CableRouteMesh({ color }: { readonly color: string }) {
|
||||
return (
|
||||
@@ -296,13 +495,23 @@ export function ElectricalMeshWithHeight({
|
||||
outletHeight={outletHeight}
|
||||
centerX={offset.cx}
|
||||
centerY={offset.cy}
|
||||
isVertical={getOutletDirection(item.metadata) === 'vertical'}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{item.type === 'SWITCH' && <SwitchMesh color={color} />}
|
||||
{item.type === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
|
||||
{item.type === 'LIGHT_CEILING' && <CeilingLightMesh color={color} wallHeight={wallHeight} />}
|
||||
{item.type === 'LIGHT_WALL' && <WallLightMesh color={color} />}
|
||||
{item.type === 'LIGHT_CEILING' && (
|
||||
<CeilingLightMesh color={color} wallHeight={wallHeight} lampSize={getCeilingLampSize(item.metadata)} />
|
||||
)}
|
||||
{item.type === 'LIGHT_WALL' && (
|
||||
<WallLightMesh
|
||||
color={color}
|
||||
style={getWallLightStyle(item.metadata)}
|
||||
cordLength={getWallLightCordLength(item.metadata)}
|
||||
lampSize={getCeilingLampSize(item.metadata)}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'CABLE_ROUTE' && <CableRouteMesh color={color} />}
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,13 @@ interface FloorCeilingProps {
|
||||
readonly shape: readonly Point[];
|
||||
readonly wallHeight: number;
|
||||
readonly floorType?: FloorType;
|
||||
/**
|
||||
* Distance (meters) a stretch ceiling hangs below the structural ceiling.
|
||||
* When > 0 a semi-transparent tinted plane is drawn at
|
||||
* `wallHeight - stretchCeilingOffset` plus a faint outline, so the user
|
||||
* can see how much vertical space is lost to the suspended ceiling.
|
||||
*/
|
||||
readonly stretchCeilingOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,16 +55,82 @@ function buildFloorGeometry(shape: readonly Point[], tileMeters: number): THREE.
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function FloorCeiling({ shape, wallHeight: _wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) {
|
||||
/**
|
||||
* Build a flat ShapeGeometry for the room polygon, without any UV rescaling.
|
||||
* Used for the stretch-ceiling overlay where the shape is tinted solid and
|
||||
* UV tiling is irrelevant.
|
||||
*/
|
||||
function buildFlatShapeGeometry(shape: readonly Point[]): THREE.ShapeGeometry | null {
|
||||
if (shape.length < 3) return null;
|
||||
const threeShape = new THREE.Shape();
|
||||
threeShape.moveTo(shape[0].x, shape[0].y);
|
||||
for (let i = 1; i < shape.length; i++) {
|
||||
threeShape.lineTo(shape[i].x, shape[i].y);
|
||||
}
|
||||
threeShape.closePath();
|
||||
return new THREE.ShapeGeometry(threeShape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline edge-pair list closing the room polygon, suitable for
|
||||
* THREE.LineSegments. The floor mesh uses
|
||||
* `rotation=[-π/2, 0, 0], scale=[1, -1, 1]`, which maps a local
|
||||
* `(x_shape, y_shape)` vertex onto world `(x_shape, 0, y_shape)`. We
|
||||
* replicate that mapping here so the outline sits exactly above the floor
|
||||
* polygon instead of being mirrored onto the opposite side.
|
||||
*/
|
||||
function buildOutlineEdgePairs(shape: readonly Point[], y: number): Float32Array {
|
||||
const n = shape.length;
|
||||
const points = new Float32Array(n * 2 * 3);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = shape[i];
|
||||
const b = shape[(i + 1) % n];
|
||||
const base = i * 6;
|
||||
points[base + 0] = a.x;
|
||||
points[base + 1] = y;
|
||||
points[base + 2] = a.y;
|
||||
points[base + 3] = b.x;
|
||||
points[base + 4] = y;
|
||||
points[base + 5] = b.y;
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function FloorCeiling({
|
||||
shape,
|
||||
wallHeight,
|
||||
floorType = 'CONCRETE',
|
||||
stretchCeilingOffset = 0,
|
||||
}: FloorCeilingProps) {
|
||||
const pbr = useMemo(() => getFloorPbr(floorType), [floorType]);
|
||||
const geometry = useMemo(
|
||||
() => buildFloorGeometry(shape, pbr.tileMeters),
|
||||
[shape, pbr.tileMeters],
|
||||
);
|
||||
|
||||
// Stretch ceiling plane geometry (only built when the feature is active).
|
||||
const stretchGeometry = useMemo(
|
||||
() => (stretchCeilingOffset > 0 ? buildFlatShapeGeometry(shape) : null),
|
||||
[shape, stretchCeilingOffset],
|
||||
);
|
||||
|
||||
const stretchY = Math.max(0, wallHeight - stretchCeilingOffset);
|
||||
|
||||
// Edge outline buffer for the stretch ceiling (closed polyline).
|
||||
const outlineGeometry = useMemo(() => {
|
||||
if (stretchCeilingOffset <= 0 || shape.length < 3) return null;
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(buildOutlineEdgePairs(shape, stretchY), 3),
|
||||
);
|
||||
return geom;
|
||||
}, [shape, stretchCeilingOffset, stretchY]);
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh
|
||||
geometry={geometry}
|
||||
material={pbr.material}
|
||||
@@ -66,5 +139,36 @@ export function FloorCeiling({ shape, wallHeight: _wallHeight, floorType = 'CONC
|
||||
position={[0, 0, 0]}
|
||||
receiveShadow
|
||||
/>
|
||||
{/* Stretch ceiling — a soft blue tinted plane facing *downward* so it
|
||||
is only visible from inside the room, plus a thin edge outline at
|
||||
the same height. The plane uses depthWrite=false so items above
|
||||
(e.g. pipes) still render correctly through it. */}
|
||||
{stretchGeometry && (
|
||||
// Same rotation+scale as the floor so the shape maps onto the same
|
||||
// world (x, -y) plane, but raised to the stretch ceiling height.
|
||||
// DoubleSide + depthWrite=false keeps it visible from both sides and
|
||||
// avoids z-fighting with objects beneath it.
|
||||
<mesh
|
||||
geometry={stretchGeometry}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
scale={[1, -1, 1]}
|
||||
position={[0, stretchY, 0]}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
color="#60a5fa"
|
||||
transparent
|
||||
opacity={0.22}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
{outlineGeometry && (
|
||||
<lineSegments>
|
||||
<bufferGeometry attach="geometry" {...outlineGeometry} />
|
||||
<lineBasicMaterial attach="material" color="#2563eb" linewidth={1} transparent opacity={0.85} />
|
||||
</lineSegments>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ export function Room3DView() {
|
||||
}, [walls]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<CameraPresetsUI
|
||||
shape={shape}
|
||||
wallHeight={wallHeight}
|
||||
@@ -266,7 +266,14 @@ export function Room3DView() {
|
||||
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
|
||||
|
||||
{/* Floor */}
|
||||
<FloorCeiling shape={shape} wallHeight={wallHeight} floorType={room.floorType} />
|
||||
<FloorCeiling
|
||||
shape={shape}
|
||||
wallHeight={wallHeight}
|
||||
floorType={room.floorType}
|
||||
stretchCeilingOffset={
|
||||
layerVisibility.stretchCeiling ? room.stretchCeilingOffset : 0
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Walls (hide the one facing the camera) */}
|
||||
{layerVisibility.walls && walls.map((wall) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import type { FloorType, WallFinish } from '@house-plan-maker/shared';
|
||||
import type { FloorType, WallFinish, FurnitureTexture } from '@house-plan-maker/shared';
|
||||
|
||||
/**
|
||||
* Loads and caches PBR texture sets (albedo + normal + roughness) for floor
|
||||
@@ -28,6 +28,7 @@ const FLOOR_TILE_METERS: Record<FloorType, number> = {
|
||||
WOOD_MEDIUM: 1.4,
|
||||
WOOD_DARK: 1.4,
|
||||
WOOD_HERRINGBONE: 1.0,
|
||||
OAK_NATURAL: 1.4,
|
||||
TILE_WHITE: 1.0,
|
||||
TILE_GRAY: 1.0,
|
||||
LAMINATE: 1.4,
|
||||
@@ -107,6 +108,42 @@ export function getWallPbr(finish: Exclude<WallFinish, 'PAINT'>): PbrSet {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ── Furniture surface textures ──
|
||||
//
|
||||
// Reuses the floor texture assets. The tiling is tighter (0.4m default) so
|
||||
// the wood grain looks appropriately scaled on desk/shelf/table surfaces
|
||||
// rather than stretched.
|
||||
|
||||
const FURNITURE_TEXTURE_TILE: Record<Exclude<FurnitureTexture, 'NONE'>, number> = {
|
||||
WOOD_LIGHT: 0.4,
|
||||
WOOD_MEDIUM: 0.4,
|
||||
WOOD_DARK: 0.4,
|
||||
WOOD_HERRINGBONE: 0.3,
|
||||
OAK_NATURAL: 0.4,
|
||||
LAMINATE: 0.4,
|
||||
CONCRETE: 0.6,
|
||||
};
|
||||
|
||||
const furnitureCache = new Map<Exclude<FurnitureTexture, 'NONE'>, PbrSet>();
|
||||
|
||||
/**
|
||||
* Get a PBR material for furniture surface textures. Reuses floor texture
|
||||
* images but with tighter tiling appropriate for furniture-scale surfaces.
|
||||
* Returns `null` for 'NONE' — the caller should fall back to the solid color.
|
||||
*/
|
||||
export function getFurnitureSurfacePbr(texture: FurnitureTexture): PbrSet | null {
|
||||
if (texture === 'NONE') return null;
|
||||
let entry = furnitureCache.get(texture);
|
||||
if (!entry) {
|
||||
entry = buildPbrSet(
|
||||
`/textures/floors/${texture.toLowerCase()}`,
|
||||
FURNITURE_TEXTURE_TILE[texture],
|
||||
);
|
||||
furnitureCache.set(texture, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute UV repeat counts for a given physical surface so the texture tiles
|
||||
* once per `tileMeters`. Apply this on the geometry side rather than the
|
||||
|
||||
@@ -35,6 +35,13 @@ export interface LayerVisibility {
|
||||
readonly furniture: boolean;
|
||||
readonly measurements: boolean;
|
||||
readonly annotations: boolean;
|
||||
/**
|
||||
* Whether the stretch-ceiling visualization (tinted plane + outline in 3D,
|
||||
* tinted band + dashed line in projection) is rendered. The underlying
|
||||
* `stretchCeilingOffset` value on the room is untouched when toggled —
|
||||
* this flag is a view-only layer switch.
|
||||
*/
|
||||
readonly stretchCeiling: boolean;
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
@@ -75,7 +82,7 @@ export interface EditorCommand {
|
||||
|
||||
export type EditorAction =
|
||||
| { readonly type: 'SET_ROOM'; readonly room: RoomFull }
|
||||
| { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial<Pick<RoomFull, 'floorType' | 'wallColor' | 'wallFinish' | 'wallHeight' | 'plinthHeight' | 'plinthThickness' | 'outletWidth' | 'outletHeight'>> }
|
||||
| { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial<Pick<RoomFull, 'floorType' | 'wallColor' | 'wallFinish' | 'wallHeight' | 'plinthHeight' | 'plinthThickness' | 'stretchCeilingOffset' | 'outletWidth' | 'outletHeight'>> }
|
||||
| { readonly type: 'SET_WALLS'; readonly walls: readonly Wall[] }
|
||||
| { readonly type: 'UPDATE_WALL'; readonly wall: Wall }
|
||||
| { readonly type: 'ADD_OPENING'; readonly opening: WallOpening }
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface CanvasColors {
|
||||
/** Stage / canvas background */
|
||||
readonly canvasBg: string;
|
||||
/** Highlighted canvas background (e.g. drag-over) */
|
||||
readonly canvasBgHighlight: string;
|
||||
|
||||
/** Thin grid lines */
|
||||
readonly gridLine: string;
|
||||
/** Major (1 m) grid lines */
|
||||
readonly gridLineMajor: string;
|
||||
|
||||
/** Ruler background strip */
|
||||
readonly rulerBg: string;
|
||||
/** Ruler text & ticks */
|
||||
readonly rulerText: string;
|
||||
|
||||
/** Wall projection fill */
|
||||
readonly wallFill: string;
|
||||
/** Wall projection stroke */
|
||||
readonly wallStroke: string;
|
||||
|
||||
/** Dimension / measurement lines */
|
||||
readonly dimensionLine: string;
|
||||
/** Dimension label text */
|
||||
readonly dimensionText: string;
|
||||
/** Coordinate-label pill background */
|
||||
readonly coordLabelBg: string;
|
||||
/** Coordinate-label pill text */
|
||||
readonly coordLabelText: string;
|
||||
|
||||
/** Electrical symbol stroke (unselected) */
|
||||
readonly electricalStroke: string;
|
||||
/** Electrical symbol fill (unselected) */
|
||||
readonly electricalFill: string;
|
||||
/** Electrical type label text */
|
||||
readonly electricalLabel: string;
|
||||
|
||||
/** Selected element stroke (shared) */
|
||||
readonly selectedStroke: string;
|
||||
/** Selected element fill (shared) */
|
||||
readonly selectedFill: string;
|
||||
/** Selected element text */
|
||||
readonly selectedText: string;
|
||||
|
||||
/** Furniture default stroke */
|
||||
readonly furnitureStroke: string;
|
||||
/** Furniture default label text */
|
||||
readonly furnitureText: string;
|
||||
|
||||
/** Opening position label text */
|
||||
readonly openingLabel: string;
|
||||
}
|
||||
|
||||
const LIGHT: CanvasColors = {
|
||||
canvasBg: '#ffffff',
|
||||
canvasBgHighlight: '#eff6ff',
|
||||
|
||||
gridLine: '#e2e8f0',
|
||||
gridLineMajor: '#cbd5e1',
|
||||
|
||||
rulerBg: '#f5f5f5',
|
||||
rulerText: '#666666',
|
||||
|
||||
wallFill: '#f8fafc',
|
||||
wallStroke: '#334155',
|
||||
|
||||
dimensionLine: '#94a3b8',
|
||||
dimensionText: '#64748b',
|
||||
coordLabelBg: 'rgba(255, 255, 255, 0.85)',
|
||||
coordLabelText: '#475569',
|
||||
|
||||
electricalStroke: '#64748b',
|
||||
electricalFill: '#f1f5f9',
|
||||
electricalLabel: '#94a3b8',
|
||||
|
||||
selectedStroke: '#2563eb',
|
||||
selectedFill: '#dbeafe',
|
||||
selectedText: '#1e40af',
|
||||
|
||||
furnitureStroke: '#6b5b3a',
|
||||
furnitureText: '#3b2f1e',
|
||||
|
||||
openingLabel: '#94a3b8',
|
||||
};
|
||||
|
||||
const DARK: CanvasColors = {
|
||||
canvasBg: '#1a1b1e',
|
||||
canvasBgHighlight: '#1e2a3a',
|
||||
|
||||
gridLine: '#2e3038',
|
||||
gridLineMajor: '#3a3d46',
|
||||
|
||||
rulerBg: '#25262b',
|
||||
rulerText: '#909296',
|
||||
|
||||
wallFill: '#2c2e33',
|
||||
wallStroke: '#a0aec0',
|
||||
|
||||
dimensionLine: '#64748b',
|
||||
dimensionText: '#94a3b8',
|
||||
coordLabelBg: 'rgba(30, 31, 34, 0.9)',
|
||||
coordLabelText: '#c1c2c5',
|
||||
|
||||
electricalStroke: '#94a3b8',
|
||||
electricalFill: '#2c2e33',
|
||||
electricalLabel: '#909296',
|
||||
|
||||
selectedStroke: '#3b82f6',
|
||||
selectedFill: '#1e3a5f',
|
||||
selectedText: '#93c5fd',
|
||||
|
||||
furnitureStroke: '#a0845c',
|
||||
furnitureText: '#d4a574',
|
||||
|
||||
openingLabel: '#64748b',
|
||||
};
|
||||
|
||||
/** Returns the canvas color palette for the current theme. */
|
||||
export function useCanvasColors(): CanvasColors {
|
||||
const { theme } = useTheme();
|
||||
return useMemo(() => (theme === 'dark' ? DARK : LIGHT), [theme]);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Helpers for reading the surface texture selection out of a FurnitureItem's
|
||||
* metadata bag. Kept standalone so both the 3D mesh and the properties panel
|
||||
* can read/write the same shape without importing each other.
|
||||
*
|
||||
* Metadata key:
|
||||
* - `surfaceTexture`: A FurnitureTexture value ('NONE' | 'WOOD_LIGHT' | …).
|
||||
* When absent or 'NONE', the furniture uses its default solid-color material.
|
||||
*/
|
||||
|
||||
import type { FurnitureTexture } from '@house-plan-maker/shared';
|
||||
|
||||
type MetadataBag = Record<string, unknown> | null | undefined;
|
||||
|
||||
/** Read the surface texture from furniture metadata. Returns 'NONE' by default. */
|
||||
export function getFurnitureTexture(metadata: MetadataBag): FurnitureTexture {
|
||||
if (!metadata) return 'NONE';
|
||||
const raw = metadata['surfaceTexture'];
|
||||
if (typeof raw === 'string') {
|
||||
return raw as FurnitureTexture;
|
||||
}
|
||||
return 'NONE';
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- Distance (meters) the stretch ceiling hangs below the structural ceiling.
|
||||
-- 0 = no stretch ceiling. Applied per room and used purely for visualization.
|
||||
ALTER TABLE "Room" ADD COLUMN "stretchCeilingOffset" REAL NOT NULL DEFAULT 0;
|
||||
@@ -27,6 +27,11 @@ model Room {
|
||||
wallHeight Float @default(2.7)
|
||||
plinthHeight Float @default(0.06)
|
||||
plinthThickness Float @default(0.01)
|
||||
/// Distance (meters) the stretch ceiling hangs *below* the structural
|
||||
/// ceiling. 0 = no stretch ceiling. The ceiling plane is therefore at
|
||||
/// `wallHeight - stretchCeilingOffset`. Display-only: does not affect
|
||||
/// wall geometry, opening positions, or electrical elevations.
|
||||
stretchCeilingOffset Float @default(0)
|
||||
order Int @default(0)
|
||||
posX Float @default(0)
|
||||
posY Float @default(0)
|
||||
|
||||
@@ -67,6 +67,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
wallHeight: input.wallHeight ?? 2.7,
|
||||
plinthHeight: input.plinthHeight ?? 0.06,
|
||||
plinthThickness: input.plinthThickness ?? 0.01,
|
||||
...(input.stretchCeilingOffset !== undefined && {
|
||||
stretchCeilingOffset: input.stretchCeilingOffset,
|
||||
}),
|
||||
order: input.order ?? 0,
|
||||
// posX/posY added in schema migration; client regeneration required
|
||||
...(input.posX !== undefined && { posX: input.posX }),
|
||||
@@ -156,6 +159,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(input.wallHeight !== undefined && { wallHeight: input.wallHeight }),
|
||||
...(input.plinthHeight !== undefined && { plinthHeight: input.plinthHeight }),
|
||||
...(input.plinthThickness !== undefined && { plinthThickness: input.plinthThickness }),
|
||||
...(input.stretchCeilingOffset !== undefined && {
|
||||
stretchCeilingOffset: input.stretchCeilingOffset,
|
||||
}),
|
||||
...(input.order !== undefined && { order: input.order }),
|
||||
...(input.posX !== undefined && { posX: input.posX }),
|
||||
...(input.posY !== undefined && { posY: input.posY }),
|
||||
@@ -237,6 +243,8 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
wallHeight: source.wallHeight,
|
||||
plinthHeight: source.plinthHeight,
|
||||
plinthThickness: source.plinthThickness,
|
||||
stretchCeilingOffset:
|
||||
(((source as Record<string, unknown>).stretchCeilingOffset as number | undefined) ?? 0),
|
||||
order: nextOrder,
|
||||
posX: source.posX,
|
||||
posY: source.posY,
|
||||
@@ -376,6 +384,7 @@ function toRoomResponse(room: {
|
||||
wallHeight: number;
|
||||
plinthHeight: number;
|
||||
plinthThickness: number;
|
||||
stretchCeilingOffset?: number | null;
|
||||
order: number;
|
||||
posX?: number | null;
|
||||
posY?: number | null;
|
||||
@@ -394,6 +403,7 @@ function toRoomResponse(room: {
|
||||
wallHeight: room.wallHeight,
|
||||
plinthHeight: room.plinthHeight,
|
||||
plinthThickness: room.plinthThickness,
|
||||
stretchCeilingOffset: room.stretchCeilingOffset ?? 0,
|
||||
order: room.order,
|
||||
posX: room.posX ?? 0,
|
||||
posY: room.posY ?? 0,
|
||||
|
||||
@@ -41,6 +41,9 @@ export type {
|
||||
VerticalAnchor,
|
||||
PositionAnchor,
|
||||
ElectricalType,
|
||||
OutletDirection,
|
||||
WallLightStyle,
|
||||
FurnitureTexture,
|
||||
ElectricalItem,
|
||||
CreateElectricalItemDto,
|
||||
UpdateElectricalItemDto,
|
||||
@@ -62,6 +65,10 @@ export {
|
||||
OPENING_TYPES,
|
||||
DOOR_OPEN_DIRECTIONS,
|
||||
ELECTRICAL_TYPES,
|
||||
OUTLET_DIRECTIONS,
|
||||
WALL_LIGHT_STYLES,
|
||||
FURNITURE_TEXTURES,
|
||||
TEXTURABLE_FURNITURE,
|
||||
FURNITURE_TYPES,
|
||||
HORIZONTAL_ANCHORS,
|
||||
VERTICAL_ANCHORS,
|
||||
|
||||
@@ -17,6 +17,10 @@ export const createRoomSchema = z.object({
|
||||
wallHeight: z.number().positive('Wall height must be positive').optional(),
|
||||
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(),
|
||||
plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(),
|
||||
stretchCeilingOffset: z
|
||||
.number()
|
||||
.min(0, 'Stretch ceiling offset must be non-negative')
|
||||
.optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
posX: z.number().optional(),
|
||||
posY: z.number().optional(),
|
||||
@@ -35,6 +39,10 @@ export const updateRoomSchema = z.object({
|
||||
wallHeight: z.number().positive('Wall height must be positive').optional(),
|
||||
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(),
|
||||
plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(),
|
||||
stretchCeilingOffset: z
|
||||
.number()
|
||||
.min(0, 'Stretch ceiling offset must be non-negative')
|
||||
.optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
posX: z.number().optional(),
|
||||
posY: z.number().optional(),
|
||||
|
||||
@@ -224,6 +224,17 @@ export const ELECTRICAL_TYPES = [
|
||||
] as const;
|
||||
export type ElectricalType = (typeof ELECTRICAL_TYPES)[number];
|
||||
|
||||
export const OUTLET_DIRECTIONS = ['horizontal', 'vertical'] as const;
|
||||
export type OutletDirection = (typeof OUTLET_DIRECTIONS)[number];
|
||||
|
||||
export const WALL_LIGHT_STYLES = [
|
||||
'classic',
|
||||
'pendant-globe',
|
||||
'sconce-up',
|
||||
'sconce-down',
|
||||
] as const;
|
||||
export type WallLightStyle = (typeof WALL_LIGHT_STYLES)[number];
|
||||
|
||||
export interface ElectricalItem {
|
||||
readonly id: string;
|
||||
readonly roomId: string;
|
||||
@@ -277,6 +288,35 @@ export interface UpdateElectricalItemDto {
|
||||
readonly metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ── FurnitureTexture ──
|
||||
|
||||
export const FURNITURE_TEXTURES = [
|
||||
'NONE',
|
||||
'WOOD_LIGHT',
|
||||
'WOOD_MEDIUM',
|
||||
'WOOD_DARK',
|
||||
'WOOD_HERRINGBONE',
|
||||
'OAK_NATURAL',
|
||||
'LAMINATE',
|
||||
'CONCRETE',
|
||||
] as const;
|
||||
export type FurnitureTexture = (typeof FURNITURE_TEXTURES)[number];
|
||||
|
||||
/** Furniture types that support surface texture selection. */
|
||||
export const TEXTURABLE_FURNITURE: readonly FurnitureType[] = [
|
||||
'DESK',
|
||||
'TABLE',
|
||||
'SHELF',
|
||||
'NIGHTSTAND',
|
||||
'DRESSER',
|
||||
'DRESSING_TABLE',
|
||||
'BOOKCASE',
|
||||
'WARDROBE',
|
||||
'CHAIR',
|
||||
'BED',
|
||||
'CRIB',
|
||||
];
|
||||
|
||||
// ── FurnitureItem ──
|
||||
|
||||
export const FURNITURE_TYPES = [
|
||||
|
||||
@@ -11,6 +11,7 @@ export const FLOOR_TYPES = [
|
||||
'WOOD_MEDIUM',
|
||||
'WOOD_DARK',
|
||||
'WOOD_HERRINGBONE',
|
||||
'OAK_NATURAL',
|
||||
'TILE_WHITE',
|
||||
'TILE_GRAY',
|
||||
'LAMINATE',
|
||||
@@ -50,6 +51,13 @@ export interface Room {
|
||||
readonly wallHeight: number;
|
||||
readonly plinthHeight: number;
|
||||
readonly plinthThickness: number;
|
||||
/**
|
||||
* Distance (meters) the stretch ceiling hangs below the structural ceiling.
|
||||
* 0 means no stretch ceiling. The effective ceiling plane is at
|
||||
* `wallHeight - stretchCeilingOffset`. Visualization only — does not
|
||||
* affect wall geometry, opening positions, or electrical elevations.
|
||||
*/
|
||||
readonly stretchCeilingOffset: number;
|
||||
readonly order: number;
|
||||
readonly posX: number;
|
||||
readonly posY: number;
|
||||
@@ -80,6 +88,7 @@ export interface CreateRoomDto {
|
||||
readonly wallHeight?: number;
|
||||
readonly plinthHeight?: number;
|
||||
readonly plinthThickness?: number;
|
||||
readonly stretchCeilingOffset?: number;
|
||||
readonly order?: number;
|
||||
readonly posX?: number;
|
||||
readonly posY?: number;
|
||||
@@ -98,6 +107,7 @@ export interface UpdateRoomDto {
|
||||
readonly wallHeight?: number;
|
||||
readonly plinthHeight?: number;
|
||||
readonly plinthThickness?: number;
|
||||
readonly stretchCeilingOffset?: number;
|
||||
readonly order?: number;
|
||||
readonly posX?: number;
|
||||
readonly posY?: number;
|
||||
|
||||
Reference in New Issue
Block a user