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.elec": "Elec",
|
||||||
"toolbar.furn": "Furn",
|
"toolbar.furn": "Furn",
|
||||||
"toolbar.meas": "Meas",
|
"toolbar.meas": "Meas",
|
||||||
|
"toolbar.stretchCeiling": "Ceiling",
|
||||||
|
"toolbar.toggleStretchCeiling": "Toggle stretch ceiling overlay",
|
||||||
"toolbar.toggleGrid": "Toggle grid",
|
"toolbar.toggleGrid": "Toggle grid",
|
||||||
"toolbar.toggleSnap": "Toggle snap",
|
"toolbar.toggleSnap": "Toggle snap",
|
||||||
"toolbar.toggleWalls": "Toggle walls layer",
|
"toolbar.toggleWalls": "Toggle walls layer",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"floor.WOOD_MEDIUM": "Medium Wood",
|
"floor.WOOD_MEDIUM": "Medium Wood",
|
||||||
"floor.WOOD_DARK": "Dark Wood",
|
"floor.WOOD_DARK": "Dark Wood",
|
||||||
"floor.WOOD_HERRINGBONE": "Herringbone",
|
"floor.WOOD_HERRINGBONE": "Herringbone",
|
||||||
|
"floor.OAK_NATURAL": "Natural Oak",
|
||||||
"floor.TILE_WHITE": "White Tile",
|
"floor.TILE_WHITE": "White Tile",
|
||||||
"floor.TILE_GRAY": "Gray Tile",
|
"floor.TILE_GRAY": "Gray Tile",
|
||||||
"floor.LAMINATE": "Laminate",
|
"floor.LAMINATE": "Laminate",
|
||||||
@@ -242,6 +245,28 @@
|
|||||||
"properties.outletWidth": "Outlet width",
|
"properties.outletWidth": "Outlet width",
|
||||||
"properties.outletHeight": "Outlet height",
|
"properties.outletHeight": "Outlet height",
|
||||||
"properties.outletCount": "Count",
|
"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",
|
"properties.anchor": "Anchor",
|
||||||
"anchor.left": "Left",
|
"anchor.left": "Left",
|
||||||
"anchor.middle": "Middle",
|
"anchor.middle": "Middle",
|
||||||
|
|||||||
@@ -152,6 +152,8 @@
|
|||||||
"toolbar.elec": "Элек",
|
"toolbar.elec": "Элек",
|
||||||
"toolbar.furn": "Мебель",
|
"toolbar.furn": "Мебель",
|
||||||
"toolbar.meas": "Разм",
|
"toolbar.meas": "Разм",
|
||||||
|
"toolbar.stretchCeiling": "Потолок",
|
||||||
|
"toolbar.toggleStretchCeiling": "Показать/скрыть натяжной потолок",
|
||||||
"toolbar.toggleGrid": "Переключить сетку",
|
"toolbar.toggleGrid": "Переключить сетку",
|
||||||
"toolbar.toggleSnap": "Переключить привязку",
|
"toolbar.toggleSnap": "Переключить привязку",
|
||||||
"toolbar.toggleWalls": "Переключить слой стен",
|
"toolbar.toggleWalls": "Переключить слой стен",
|
||||||
@@ -225,6 +227,7 @@
|
|||||||
"floor.WOOD_MEDIUM": "Среднее дерево",
|
"floor.WOOD_MEDIUM": "Среднее дерево",
|
||||||
"floor.WOOD_DARK": "Тёмное дерево",
|
"floor.WOOD_DARK": "Тёмное дерево",
|
||||||
"floor.WOOD_HERRINGBONE": "Ёлочка",
|
"floor.WOOD_HERRINGBONE": "Ёлочка",
|
||||||
|
"floor.OAK_NATURAL": "Натуральный дуб",
|
||||||
"floor.TILE_WHITE": "Белая плитка",
|
"floor.TILE_WHITE": "Белая плитка",
|
||||||
"floor.TILE_GRAY": "Серая плитка",
|
"floor.TILE_GRAY": "Серая плитка",
|
||||||
"floor.LAMINATE": "Ламинат",
|
"floor.LAMINATE": "Ламинат",
|
||||||
@@ -245,6 +248,28 @@
|
|||||||
"properties.outletWidth": "Ширина розетки",
|
"properties.outletWidth": "Ширина розетки",
|
||||||
"properties.outletHeight": "Высота розетки",
|
"properties.outletHeight": "Высота розетки",
|
||||||
"properties.outletCount": "Количество",
|
"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": "Привязка",
|
"properties.anchor": "Привязка",
|
||||||
"anchor.left": "Слева",
|
"anchor.left": "Слева",
|
||||||
"anchor.middle": "По центру",
|
"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 { startMeasurement, updateMeasurement, finishMeasurement } from './tools/MeasureTool';
|
||||||
import { ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
import { ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
||||||
import { FURNITURE_DEFS } from './symbols/furniture';
|
import { FURNITURE_DEFS } from './symbols/furniture';
|
||||||
|
import { useCanvasColors } from './utils/canvasThemeColors';
|
||||||
import { AnnotationLayer } from './layers/AnnotationLayer';
|
import { AnnotationLayer } from './layers/AnnotationLayer';
|
||||||
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
|
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
|
||||||
import { generateLocalId } from './utils/geometry';
|
import { generateLocalId } from './utils/geometry';
|
||||||
@@ -53,6 +54,7 @@ const TOOL_CURSORS: Record<string, string> = {
|
|||||||
|
|
||||||
export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const canvasColors = useCanvasColors();
|
||||||
const { zoom, panOffset, setZoom, setPanOffset } = useZoomPan();
|
const { zoom, panOffset, setZoom, setPanOffset } = useZoomPan();
|
||||||
const {
|
const {
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -169,7 +171,11 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
|||||||
const worldPoint = getWorldPoint(e);
|
const worldPoint = getWorldPoint(e);
|
||||||
|
|
||||||
if (activeTool === 'select') {
|
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 (hit) {
|
||||||
if (e.evt.shiftKey) {
|
if (e.evt.shiftKey) {
|
||||||
@@ -464,7 +470,9 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
|||||||
|
|
||||||
// Only if the rectangle is non-trivial
|
// Only if the rectangle is non-trivial
|
||||||
if (rect.width > 0.01 || rect.height > 0.01) {
|
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) {
|
if (ids.size > 0) {
|
||||||
selectionDispatch({ type: 'SET_SELECTED', ids });
|
selectionDispatch({ type: 'SET_SELECTED', ids });
|
||||||
}
|
}
|
||||||
@@ -509,7 +517,7 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
|||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
style={{ cursor, background: '#ffffff' }}
|
style={{ cursor, background: canvasColors.canvasBg }}
|
||||||
>
|
>
|
||||||
{/*
|
{/*
|
||||||
Konva renders one HTML <canvas> per <Layer>; performance recommends 3-5
|
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')}
|
{t('toolbar.meas')}
|
||||||
</button>
|
</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
|
<label
|
||||||
title={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
|
title={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import { useMemo, useState, useCallback } from 'react';
|
import { useMemo, useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { 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 { useEditor } from './context/EditorContext';
|
||||||
import { useUndoRedo } from './context/UndoRedoContext';
|
import { useUndoRedo } from './context/UndoRedoContext';
|
||||||
import { wallLength } from './utils/wallUtils';
|
import { wallLength } from './utils/wallUtils';
|
||||||
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
|
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
|
||||||
import { normalizeAngleDegrees } from './utils/angle';
|
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 {
|
import {
|
||||||
getCurtainLeftOpen,
|
getCurtainLeftOpen,
|
||||||
getCurtainRightOpen,
|
getCurtainRightOpen,
|
||||||
getCurtainFabricColor,
|
getCurtainFabricColor,
|
||||||
} from './utils/curtainMetadata';
|
} from './utils/curtainMetadata';
|
||||||
|
import { getFurnitureTexture } from './utils/furnitureTextureMetadata';
|
||||||
import type { EditorCommand } from './types';
|
import type { EditorCommand } from './types';
|
||||||
import styles from './properties-panel.module.css';
|
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.wallHeight')} value={`${room.wallHeight}m`} />
|
||||||
<PropertyRow label={t('properties.plinthHeight')} value={`${Math.round(room.plinthHeight * 1000) / 10}cm`} />
|
<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>
|
<SelectPropertyRow<FloorType>
|
||||||
label={t('properties.floorType')}
|
label={t('properties.floorType')}
|
||||||
value={room.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 {
|
interface EditablePropertyRowProps {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly value: string;
|
readonly value: string;
|
||||||
@@ -876,8 +921,45 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
|||||||
|
|
||||||
const isWallMounted = item.wallId !== null;
|
const isWallMounted = item.wallId !== null;
|
||||||
const isOutlet = item.type === 'OUTLET';
|
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 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(
|
const handleXChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const num = parseFloat(value);
|
const num = parseFloat(value);
|
||||||
@@ -927,6 +1009,51 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
|||||||
placeholder={labelPlaceholder}
|
placeholder={labelPlaceholder}
|
||||||
onChange={(label) => onUpdate({ ...item, label })}
|
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 && (
|
{isOutlet && (
|
||||||
<EditablePropertyRow
|
<EditablePropertyRow
|
||||||
label={countLabel}
|
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.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.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} />
|
<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) {
|
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const displayLabel = stripFurnitureMarkers(item.label);
|
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
|
// Furniture's "default" label for the placeholder is the type code; we
|
||||||
// don't have access to the original FurnitureDef from a placed item.
|
// don't have access to the original FurnitureDef from a placed item.
|
||||||
const labelPlaceholder = item.type;
|
const labelPlaceholder = item.type;
|
||||||
@@ -1064,6 +1243,19 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
|||||||
onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) })
|
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.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.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} />
|
<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.wallHeight !== saved.room.wallHeight ||
|
||||||
state.room.plinthHeight !== saved.room.plinthHeight ||
|
state.room.plinthHeight !== saved.room.plinthHeight ||
|
||||||
state.room.plinthThickness !== saved.room.plinthThickness ||
|
state.room.plinthThickness !== saved.room.plinthThickness ||
|
||||||
|
state.room.stretchCeilingOffset !== saved.room.stretchCeilingOffset ||
|
||||||
state.room.outletWidth !== saved.room.outletWidth ||
|
state.room.outletWidth !== saved.room.outletWidth ||
|
||||||
state.room.outletHeight !== saved.room.outletHeight ||
|
state.room.outletHeight !== saved.room.outletHeight ||
|
||||||
state.room.name !== saved.room.name;
|
state.room.name !== saved.room.name;
|
||||||
@@ -100,6 +101,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
state.room.wallHeight,
|
state.room.wallHeight,
|
||||||
state.room.plinthHeight,
|
state.room.plinthHeight,
|
||||||
state.room.plinthThickness,
|
state.room.plinthThickness,
|
||||||
|
state.room.stretchCeilingOffset,
|
||||||
state.room.outletWidth,
|
state.room.outletWidth,
|
||||||
state.room.outletHeight,
|
state.room.outletHeight,
|
||||||
state.room.name,
|
state.room.name,
|
||||||
@@ -277,6 +279,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
wallHeight: state.room.wallHeight,
|
wallHeight: state.room.wallHeight,
|
||||||
plinthHeight: state.room.plinthHeight,
|
plinthHeight: state.room.plinthHeight,
|
||||||
plinthThickness: state.room.plinthThickness,
|
plinthThickness: state.room.plinthThickness,
|
||||||
|
stretchCeilingOffset: state.room.stretchCeilingOffset,
|
||||||
outletWidth: state.room.outletWidth,
|
outletWidth: state.room.outletWidth,
|
||||||
outletHeight: state.room.outletHeight,
|
outletHeight: state.room.outletHeight,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function createInitialState(room: RoomFull): EditorState {
|
|||||||
gridVisible: true,
|
gridVisible: true,
|
||||||
snapEnabled: true,
|
snapEnabled: true,
|
||||||
snapGranularity: DEFAULT_GRID_SIZE,
|
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,
|
selectedElectricalIndex: null,
|
||||||
selectedFurnitureIndex: null,
|
selectedFurnitureIndex: null,
|
||||||
annotations: room.annotations ?? [],
|
annotations: room.annotations ?? [],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Group, Line, Text, Rect } from 'react-konva';
|
import { Group, Line, Text, Rect } from 'react-konva';
|
||||||
import type { Point } from '@house-plan-maker/shared';
|
import type { Point } from '@house-plan-maker/shared';
|
||||||
|
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||||
|
|
||||||
interface GridLayerProps {
|
interface GridLayerProps {
|
||||||
readonly zoom: number;
|
readonly zoom: number;
|
||||||
@@ -10,15 +11,6 @@ interface GridLayerProps {
|
|||||||
readonly gridSize: number;
|
readonly gridSize: number;
|
||||||
readonly visible: boolean;
|
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. */
|
/** Ruler size in pixels. */
|
||||||
const RULER_SIZE = 24;
|
const RULER_SIZE = 24;
|
||||||
|
|
||||||
@@ -30,6 +22,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
gridSize,
|
gridSize,
|
||||||
visible,
|
visible,
|
||||||
}: GridLayerProps) {
|
}: GridLayerProps) {
|
||||||
|
const colors = useCanvasColors();
|
||||||
const gridLines = useMemo(() => {
|
const gridLines = useMemo(() => {
|
||||||
if (!visible) return { lines: [], majorLines: [] };
|
if (!visible) return { lines: [], majorLines: [] };
|
||||||
|
|
||||||
@@ -136,7 +129,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
<Line
|
<Line
|
||||||
key={`g-${i}`}
|
key={`g-${i}`}
|
||||||
points={line.points}
|
points={line.points}
|
||||||
stroke={GRID_LINE_COLOR}
|
stroke={colors.gridLine}
|
||||||
strokeWidth={0.5}
|
strokeWidth={0.5}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
@@ -146,7 +139,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
<Line
|
<Line
|
||||||
key={`gm-${i}`}
|
key={`gm-${i}`}
|
||||||
points={line.points}
|
points={line.points}
|
||||||
stroke={MAJOR_GRID_LINE_COLOR}
|
stroke={colors.gridLineMajor}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
@@ -158,7 +151,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
y={0}
|
y={0}
|
||||||
width={stageWidth}
|
width={stageWidth}
|
||||||
height={RULER_SIZE}
|
height={RULER_SIZE}
|
||||||
fill={RULER_BG_COLOR}
|
fill={colors.rulerBg}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
{rulerMarks.hMarks.map((mark, i) => (
|
{rulerMarks.hMarks.map((mark, i) => (
|
||||||
@@ -168,7 +161,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
y={4}
|
y={4}
|
||||||
text={mark.label}
|
text={mark.label}
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fill={RULER_TEXT_COLOR}
|
fill={colors.rulerText}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -176,7 +169,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
<Line
|
<Line
|
||||||
key={`rht-${i}`}
|
key={`rht-${i}`}
|
||||||
points={[mark.screenX, RULER_SIZE - 6, mark.screenX, RULER_SIZE]}
|
points={[mark.screenX, RULER_SIZE - 6, mark.screenX, RULER_SIZE]}
|
||||||
stroke={RULER_TEXT_COLOR}
|
stroke={colors.rulerText}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
@@ -188,7 +181,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
y={0}
|
y={0}
|
||||||
width={RULER_SIZE}
|
width={RULER_SIZE}
|
||||||
height={stageHeight}
|
height={stageHeight}
|
||||||
fill={RULER_BG_COLOR}
|
fill={colors.rulerBg}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
{rulerMarks.vMarks.map((mark, i) => (
|
{rulerMarks.vMarks.map((mark, i) => (
|
||||||
@@ -198,7 +191,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
y={mark.screenY - 4}
|
y={mark.screenY - 4}
|
||||||
text={mark.label}
|
text={mark.label}
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fill={RULER_TEXT_COLOR}
|
fill={colors.rulerText}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -206,7 +199,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
<Line
|
<Line
|
||||||
key={`rvt-${i}`}
|
key={`rvt-${i}`}
|
||||||
points={[RULER_SIZE - 6, mark.screenY, RULER_SIZE, mark.screenY]}
|
points={[RULER_SIZE - 6, mark.screenY, RULER_SIZE, mark.screenY]}
|
||||||
stroke={RULER_TEXT_COLOR}
|
stroke={colors.rulerText}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
@@ -218,7 +211,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
y={0}
|
y={0}
|
||||||
width={RULER_SIZE}
|
width={RULER_SIZE}
|
||||||
height={RULER_SIZE}
|
height={RULER_SIZE}
|
||||||
fill={RULER_BG_COLOR}
|
fill={colors.rulerBg}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Group, Rect, Line, Text } from 'react-konva';
|
|||||||
import type { ProjectedOpening } from '../utils/projectionMapping';
|
import type { ProjectedOpening } from '../utils/projectionMapping';
|
||||||
import { projectionToPixel } from '../utils/projectionMapping';
|
import { projectionToPixel } from '../utils/projectionMapping';
|
||||||
import type { DoorOpenDirection } from '@house-plan-maker/shared';
|
import type { DoorOpenDirection } from '@house-plan-maker/shared';
|
||||||
|
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||||
|
|
||||||
interface ProjectionDoorProps {
|
interface ProjectionDoorProps {
|
||||||
readonly projected: ProjectedOpening;
|
readonly projected: ProjectedOpening;
|
||||||
@@ -67,6 +68,7 @@ export function ProjectionDoor({
|
|||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
}: ProjectionDoorProps) {
|
}: ProjectionDoorProps) {
|
||||||
|
const colors = useCanvasColors();
|
||||||
const { rect, opening } = projected;
|
const { rect, opening } = projected;
|
||||||
const openDirection: DoorOpenDirection = opening.openDirection ?? 'LEFT';
|
const openDirection: DoorOpenDirection = opening.openDirection ?? 'LEFT';
|
||||||
|
|
||||||
@@ -102,7 +104,7 @@ export function ProjectionDoor({
|
|||||||
y={topLeft.y - 2}
|
y={topLeft.y - 2}
|
||||||
width={pxWidth + 4}
|
width={pxWidth + 4}
|
||||||
height={pxHeight + 4}
|
height={pxHeight + 4}
|
||||||
stroke="#2563eb"
|
stroke={colors.selectedStroke}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
dash={[3, 3]}
|
dash={[3, 3]}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
@@ -114,20 +116,20 @@ export function ProjectionDoor({
|
|||||||
y={topLeft.y}
|
y={topLeft.y}
|
||||||
width={pxWidth}
|
width={pxWidth}
|
||||||
height={pxHeight}
|
height={pxHeight}
|
||||||
fill="#ffffff"
|
fill={colors.canvasBg}
|
||||||
stroke={isSelected ? '#2563eb' : '#64748b'}
|
stroke={isSelected ? colors.selectedStroke : colors.electricalStroke}
|
||||||
strokeWidth={isSelected ? 2 : 1}
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
/>
|
/>
|
||||||
{/* Door leaf line (vertical line showing hinge side) */}
|
{/* Door leaf line (vertical line showing hinge side) */}
|
||||||
<Line
|
<Line
|
||||||
points={leafPoints}
|
points={leafPoints}
|
||||||
stroke="#94a3b8"
|
stroke={colors.dimensionLine}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
{/* Door swing indicator arc */}
|
{/* Door swing indicator arc */}
|
||||||
<Line
|
<Line
|
||||||
points={arcPoints}
|
points={arcPoints}
|
||||||
stroke="#94a3b8"
|
stroke={colors.dimensionLine}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
dash={[4, 3]}
|
dash={[4, 3]}
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +141,7 @@ export function ProjectionDoor({
|
|||||||
text="D"
|
text="D"
|
||||||
align="center"
|
align="center"
|
||||||
fontSize={11}
|
fontSize={11}
|
||||||
fill="#64748b"
|
fill={colors.dimensionText}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</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 type { ProjectedElectrical } from '../utils/projectionMapping';
|
||||||
import { projectionToPixel } from '../utils/projectionMapping';
|
import { projectionToPixel } from '../utils/projectionMapping';
|
||||||
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
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 {
|
interface ProjectionElectricalProps {
|
||||||
readonly projected: ProjectedElectrical;
|
readonly projected: ProjectedElectrical;
|
||||||
@@ -37,6 +39,7 @@ export function ProjectionElectrical({
|
|||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
}: ProjectionElectricalProps) {
|
}: ProjectionElectricalProps) {
|
||||||
|
const colors = useCanvasColors();
|
||||||
const { position, item } = projected;
|
const { position, item } = projected;
|
||||||
|
|
||||||
const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor;
|
const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor;
|
||||||
@@ -50,8 +53,8 @@ export function ProjectionElectrical({
|
|||||||
padding,
|
padding,
|
||||||
);
|
);
|
||||||
|
|
||||||
const strokeColor = isSelected ? '#2563eb' : '#64748b';
|
const strokeColor = isSelected ? colors.selectedStroke : colors.electricalStroke;
|
||||||
const fillColor = isSelected ? '#dbeafe' : '#f1f5f9';
|
const fillColor = isSelected ? colors.selectedFill : colors.electricalFill;
|
||||||
const half = SYMBOL_SIZE / 2;
|
const half = SYMBOL_SIZE / 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +74,7 @@ export function ProjectionElectrical({
|
|||||||
y={center.y - half - 2}
|
y={center.y - half - 2}
|
||||||
width={SYMBOL_SIZE + 4}
|
width={SYMBOL_SIZE + 4}
|
||||||
height={SYMBOL_SIZE + 4}
|
height={SYMBOL_SIZE + 4}
|
||||||
stroke="#2563eb"
|
stroke={colors.selectedStroke}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
dash={[3, 3]}
|
dash={[3, 3]}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
@@ -79,6 +82,8 @@ export function ProjectionElectrical({
|
|||||||
)}
|
)}
|
||||||
{item.type === 'OUTLET' && (() => {
|
{item.type === 'OUTLET' && (() => {
|
||||||
const safeCount = Math.max(1, Math.round(item.count));
|
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.
|
// Convert physical outlet dims to projection-pixel dims.
|
||||||
const wPx = outletWidth * scale;
|
const wPx = outletWidth * scale;
|
||||||
const hPx = outletHeight * scale;
|
const hPx = outletHeight * scale;
|
||||||
@@ -100,15 +105,17 @@ export function ProjectionElectrical({
|
|||||||
? 'left'
|
? 'left'
|
||||||
: 'middle'
|
: 'middle'
|
||||||
: anchor.horizontal;
|
: 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 =
|
const offX =
|
||||||
mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0;
|
mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0;
|
||||||
const offY =
|
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 cx = center.x + offX;
|
||||||
const cy = center.y + offY;
|
const cy = center.y + offY;
|
||||||
const left = cx - totalW / 2;
|
const groupLeft = cx - totalW / 2;
|
||||||
const top = cy - hPx / 2;
|
const groupTop = cy - totalH / 2;
|
||||||
const cellMin = Math.min(wPx, hPx);
|
const cellMin = Math.min(wPx, hPx);
|
||||||
const faceR = cellMin * 0.32;
|
const faceR = cellMin * 0.32;
|
||||||
const prongL = cellMin * 0.18;
|
const prongL = cellMin * 0.18;
|
||||||
@@ -116,14 +123,15 @@ export function ProjectionElectrical({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: safeCount }).map((_, i) => {
|
{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 cellCx = cellLeft + wPx / 2;
|
||||||
const cellCy = top + hPx / 2;
|
const cellCy = cellTop + hPx / 2;
|
||||||
return (
|
return (
|
||||||
<Group key={i}>
|
<Group key={i}>
|
||||||
<Rect
|
<Rect
|
||||||
x={cellLeft}
|
x={cellLeft}
|
||||||
y={top}
|
y={cellTop}
|
||||||
width={wPx}
|
width={wPx}
|
||||||
height={hPx}
|
height={hPx}
|
||||||
cornerRadius={Math.max(1, cellMin * 0.12)}
|
cornerRadius={Math.max(1, cellMin * 0.12)}
|
||||||
@@ -178,35 +186,110 @@ export function ProjectionElectrical({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{item.type === 'LIGHT_WALL' && (
|
{item.type === 'LIGHT_WALL' && (() => {
|
||||||
<>
|
const wallLightStyle = getWallLightStyle(item.metadata);
|
||||||
{/* Wall light: semicircle shape */}
|
if (wallLightStyle === 'pendant-globe') {
|
||||||
<Circle
|
// Pendant globe: gooseneck line + hanging cord + circle globe
|
||||||
x={center.x}
|
const armTop = center.y - half * 1.8;
|
||||||
y={center.y}
|
const cordBottom = center.y + half * 0.6;
|
||||||
radius={half}
|
return (
|
||||||
fill="#fef9c3"
|
<>
|
||||||
stroke={strokeColor}
|
{/* Wall mount dot */}
|
||||||
strokeWidth={1.5}
|
<Circle x={center.x} y={center.y} radius={3} fill="#b8860b" stroke={strokeColor} strokeWidth={1} />
|
||||||
/>
|
{/* Gooseneck arm going up */}
|
||||||
<Line
|
<Line points={[center.x, center.y, center.x, armTop]} stroke="#b8860b" strokeWidth={1.5} />
|
||||||
points={[
|
{/* Arm curving outward */}
|
||||||
center.x - half, center.y,
|
<Line points={[center.x, armTop, center.x + half * 0.8, armTop]} stroke="#b8860b" strokeWidth={1.5} />
|
||||||
center.x - half * 1.3, center.y + half * 0.8,
|
{/* Cord hanging down */}
|
||||||
]}
|
<Line points={[center.x + half * 0.8, armTop, center.x + half * 0.8, cordBottom]} stroke="#d4c090" strokeWidth={1} />
|
||||||
stroke="#eab308"
|
{/* Glass globe */}
|
||||||
strokeWidth={1}
|
<Circle
|
||||||
/>
|
x={center.x + half * 0.8}
|
||||||
<Line
|
y={cordBottom + half * 0.5}
|
||||||
points={[
|
radius={half * 0.7}
|
||||||
center.x + half, center.y,
|
fill="rgba(254, 249, 195, 0.4)"
|
||||||
center.x + half * 1.3, center.y + half * 0.8,
|
stroke="#eab308"
|
||||||
]}
|
strokeWidth={1}
|
||||||
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 (
|
||||||
|
<>
|
||||||
|
<Circle
|
||||||
|
x={center.x}
|
||||||
|
y={center.y}
|
||||||
|
radius={half}
|
||||||
|
fill="#fef9c3"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[
|
||||||
|
center.x - half, center.y,
|
||||||
|
center.x - half * 1.3, center.y + half * 0.8,
|
||||||
|
]}
|
||||||
|
stroke="#eab308"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[
|
||||||
|
center.x + half, center.y,
|
||||||
|
center.x + half * 1.3, center.y + half * 0.8,
|
||||||
|
]}
|
||||||
|
stroke="#eab308"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Fallback for other wall-mounted types */}
|
{/* Fallback for other wall-mounted types */}
|
||||||
{item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && (
|
{item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && (
|
||||||
<Rect
|
<Rect
|
||||||
@@ -236,7 +319,7 @@ export function ProjectionElectrical({
|
|||||||
}
|
}
|
||||||
align="center"
|
align="center"
|
||||||
fontSize={8}
|
fontSize={8}
|
||||||
fill="#94a3b8"
|
fill={colors.electricalLabel}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Group, Rect, Text } from 'react-konva';
|
import { Group, Rect, Text } from 'react-konva';
|
||||||
import type { ProjectedFurniture } from '../utils/projectionMapping';
|
import type { ProjectedFurniture } from '../utils/projectionMapping';
|
||||||
import { projectionToPixel } from '../utils/projectionMapping';
|
import { projectionToPixel } from '../utils/projectionMapping';
|
||||||
|
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||||
|
|
||||||
interface ProjectionFurnitureProps {
|
interface ProjectionFurnitureProps {
|
||||||
readonly projected: ProjectedFurniture;
|
readonly projected: ProjectedFurniture;
|
||||||
@@ -9,7 +10,8 @@ interface ProjectionFurnitureProps {
|
|||||||
readonly padding: number;
|
readonly padding: number;
|
||||||
readonly isSelected: boolean;
|
readonly isSelected: boolean;
|
||||||
readonly globalOpacity?: number;
|
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> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -31,6 +33,7 @@ export function ProjectionFurniture({
|
|||||||
globalOpacity = 1,
|
globalOpacity = 1,
|
||||||
onClick,
|
onClick,
|
||||||
}: ProjectionFurnitureProps) {
|
}: ProjectionFurnitureProps) {
|
||||||
|
const colors = useCanvasColors();
|
||||||
const { rect, item } = projected;
|
const { rect, item } = projected;
|
||||||
|
|
||||||
const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
|
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 color = TYPE_COLORS[item.type] ?? '#a0845c';
|
||||||
|
|
||||||
const itemOpacity = (item.opacity ?? 1) * globalOpacity;
|
const itemOpacity = (item.opacity ?? 1) * globalOpacity;
|
||||||
|
const interactive = onClick !== undefined;
|
||||||
return (
|
return (
|
||||||
<Group onClick={onClick} opacity={itemOpacity}>
|
<Group onClick={onClick} opacity={itemOpacity} listening={interactive}>
|
||||||
<Rect
|
<Rect
|
||||||
x={topLeft.x}
|
x={topLeft.x}
|
||||||
y={topLeft.y}
|
y={topLeft.y}
|
||||||
width={pxWidth}
|
width={pxWidth}
|
||||||
height={pxHeight}
|
height={pxHeight}
|
||||||
fill={isSelected ? '#dbeafe' : color}
|
fill={isSelected ? colors.selectedFill : color}
|
||||||
stroke={isSelected ? '#2563eb' : '#6b5b3a'}
|
stroke={isSelected ? colors.selectedStroke : colors.furnitureStroke}
|
||||||
strokeWidth={isSelected ? 2 : 1}
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
opacity={0.7}
|
opacity={0.7}
|
||||||
/>
|
/>
|
||||||
@@ -60,7 +64,7 @@ export function ProjectionFurniture({
|
|||||||
text={item.label ?? item.type}
|
text={item.label ?? item.type}
|
||||||
align="center"
|
align="center"
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fill={isSelected ? '#1e40af' : '#3b2f1e'}
|
fill={isSelected ? colors.selectedText : colors.furnitureText}
|
||||||
ellipsis
|
ellipsis
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Group, Rect, Line, Text } from 'react-konva';
|
import { Group, Rect, Line, Text } from 'react-konva';
|
||||||
|
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||||
import type {
|
import type {
|
||||||
ProjectedOpening,
|
ProjectedOpening,
|
||||||
ProjectedElectrical,
|
ProjectedElectrical,
|
||||||
@@ -7,6 +8,7 @@ import type {
|
|||||||
} from '../utils/projectionMapping';
|
} from '../utils/projectionMapping';
|
||||||
import { projectionToPixel } from '../utils/projectionMapping';
|
import { projectionToPixel } from '../utils/projectionMapping';
|
||||||
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||||
|
import { getOutletInvertCoordX, getOutletInvertCoordY } from '../symbols/electrical';
|
||||||
|
|
||||||
interface ProjectionMeasurementsProps {
|
interface ProjectionMeasurementsProps {
|
||||||
readonly projectedOpenings: readonly ProjectedOpening[];
|
readonly projectedOpenings: readonly ProjectedOpening[];
|
||||||
@@ -25,7 +27,7 @@ interface ProjectionMeasurementsProps {
|
|||||||
|
|
||||||
/** Dimension line with arrows and text. */
|
/** Dimension line with arrows and text. */
|
||||||
function DimensionLine({
|
function DimensionLine({
|
||||||
x1, y1, x2, y2, label, offset, horizontal,
|
x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor,
|
||||||
}: {
|
}: {
|
||||||
readonly x1: number;
|
readonly x1: number;
|
||||||
readonly y1: number;
|
readonly y1: number;
|
||||||
@@ -34,6 +36,8 @@ function DimensionLine({
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
readonly horizontal: boolean;
|
readonly horizontal: boolean;
|
||||||
|
readonly lineColor: string;
|
||||||
|
readonly textColor: string;
|
||||||
}) {
|
}) {
|
||||||
const arrowSize = 4;
|
const arrowSize = 4;
|
||||||
|
|
||||||
@@ -43,19 +47,19 @@ function DimensionLine({
|
|||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
{/* Extension lines */}
|
{/* Extension lines */}
|
||||||
<Line points={[x1, y1, x1, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
|
<Line points={[x1, y1, x1, lineY]} stroke={lineColor} strokeWidth={0.5} />
|
||||||
<Line points={[x2, y2, x2, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
|
<Line points={[x2, y2, x2, lineY]} stroke={lineColor} strokeWidth={0.5} />
|
||||||
{/* Main line */}
|
{/* 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 */}
|
{/* Arrows */}
|
||||||
<Line
|
<Line
|
||||||
points={[x1, lineY, x1 + arrowSize, lineY - arrowSize / 2, x1 + arrowSize, lineY + arrowSize / 2]}
|
points={[x1, lineY, x1 + arrowSize, lineY - arrowSize / 2, x1 + arrowSize, lineY + arrowSize / 2]}
|
||||||
fill="#94a3b8"
|
fill={lineColor}
|
||||||
closed
|
closed
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
points={[x2, lineY, x2 - arrowSize, lineY - arrowSize / 2, x2 - arrowSize, lineY + arrowSize / 2]}
|
points={[x2, lineY, x2 - arrowSize, lineY - arrowSize / 2, x2 - arrowSize, lineY + arrowSize / 2]}
|
||||||
fill="#94a3b8"
|
fill={lineColor}
|
||||||
closed
|
closed
|
||||||
/>
|
/>
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
@@ -66,7 +70,7 @@ function DimensionLine({
|
|||||||
text={label}
|
text={label}
|
||||||
align="center"
|
align="center"
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fill="#64748b"
|
fill={textColor}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -77,17 +81,17 @@ function DimensionLine({
|
|||||||
const midY = (y1 + y2) / 2;
|
const midY = (y1 + y2) / 2;
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
<Line points={[x1, y1, lineX, y1]} stroke="#94a3b8" strokeWidth={0.5} />
|
<Line points={[x1, y1, lineX, y1]} stroke={lineColor} strokeWidth={0.5} />
|
||||||
<Line points={[x1, y2, lineX, y2]} stroke="#94a3b8" strokeWidth={0.5} />
|
<Line points={[x1, y2, lineX, y2]} stroke={lineColor} strokeWidth={0.5} />
|
||||||
<Line points={[lineX, y1, lineX, y2]} stroke="#94a3b8" strokeWidth={0.75} />
|
<Line points={[lineX, y1, lineX, y2]} stroke={lineColor} strokeWidth={0.75} />
|
||||||
<Line
|
<Line
|
||||||
points={[lineX, y1, lineX - arrowSize / 2, y1 + arrowSize, lineX + arrowSize / 2, y1 + arrowSize]}
|
points={[lineX, y1, lineX - arrowSize / 2, y1 + arrowSize, lineX + arrowSize / 2, y1 + arrowSize]}
|
||||||
fill="#94a3b8"
|
fill={lineColor}
|
||||||
closed
|
closed
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
points={[lineX, y2, lineX - arrowSize / 2, y2 - arrowSize, lineX + arrowSize / 2, y2 - arrowSize]}
|
points={[lineX, y2, lineX - arrowSize / 2, y2 - arrowSize, lineX + arrowSize / 2, y2 - arrowSize]}
|
||||||
fill="#94a3b8"
|
fill={lineColor}
|
||||||
closed
|
closed
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -95,7 +99,7 @@ function DimensionLine({
|
|||||||
y={midY - 5}
|
y={midY - 5}
|
||||||
text={label}
|
text={label}
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fill="#64748b"
|
fill={textColor}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -118,6 +122,7 @@ export function ProjectionMeasurements({
|
|||||||
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||||
showWallDimensions = true,
|
showWallDimensions = true,
|
||||||
}: ProjectionMeasurementsProps) {
|
}: ProjectionMeasurementsProps) {
|
||||||
|
const colors = useCanvasColors();
|
||||||
const elements: ReactNode[] = [];
|
const elements: ReactNode[] = [];
|
||||||
|
|
||||||
if (showWallDimensions) {
|
if (showWallDimensions) {
|
||||||
@@ -134,6 +139,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(wallLen)}
|
label={formatM(wallLen)}
|
||||||
offset={18}
|
offset={18}
|
||||||
horizontal
|
horizontal
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -150,6 +157,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(wallHeight)}
|
label={formatM(wallHeight)}
|
||||||
offset={18}
|
offset={18}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,6 +180,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.height)}
|
label={formatM(rect.height)}
|
||||||
offset={-14}
|
offset={-14}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -187,6 +198,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.y)}
|
label={formatM(rect.y)}
|
||||||
offset={-14}
|
offset={-14}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,6 +216,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.width)}
|
label={formatM(rect.width)}
|
||||||
offset={-12}
|
offset={-12}
|
||||||
horizontal
|
horizontal
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -229,7 +244,14 @@ export function ProjectionMeasurements({
|
|||||||
halfWidthPx = (safeCount * outletWidth * scale) / 2;
|
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 labelX = center.x + halfWidthPx + 6;
|
||||||
const labelY = center.y - 6;
|
const labelY = center.y - 6;
|
||||||
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
|
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
|
||||||
@@ -241,7 +263,7 @@ export function ProjectionMeasurements({
|
|||||||
y={labelY - 1}
|
y={labelY - 1}
|
||||||
width={labelWidth}
|
width={labelWidth}
|
||||||
height={12}
|
height={12}
|
||||||
fill="rgba(255, 255, 255, 0.85)"
|
fill={colors.coordLabelBg}
|
||||||
cornerRadius={2}
|
cornerRadius={2}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
@@ -250,7 +272,7 @@ export function ProjectionMeasurements({
|
|||||||
y={labelY}
|
y={labelY}
|
||||||
text={coordLabel}
|
text={coordLabel}
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fill="#475569"
|
fill={colors.coordLabelText}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
</Group>,
|
</Group>,
|
||||||
@@ -274,7 +296,7 @@ export function ProjectionMeasurements({
|
|||||||
y={openingCenter.y - 22}
|
y={openingCenter.y - 22}
|
||||||
text={formatM(rect.x + rect.width / 2)}
|
text={formatM(rect.x + rect.width / 2)}
|
||||||
fontSize={8}
|
fontSize={8}
|
||||||
fill="#94a3b8"
|
fill={colors.openingLabel}
|
||||||
align="center"
|
align="center"
|
||||||
width={32}
|
width={32}
|
||||||
/>,
|
/>,
|
||||||
@@ -313,6 +335,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.width)}
|
label={formatM(rect.width)}
|
||||||
offset={32}
|
offset={32}
|
||||||
horizontal
|
horizontal
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
if (rect.x > 0.001) {
|
if (rect.x > 0.001) {
|
||||||
@@ -327,6 +351,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.x)}
|
label={formatM(rect.x)}
|
||||||
offset={46}
|
offset={46}
|
||||||
horizontal
|
horizontal
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -342,6 +368,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.width)}
|
label={formatM(rect.width)}
|
||||||
offset={14}
|
offset={14}
|
||||||
horizontal
|
horizontal
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// Inline start-offset (distance from wall start to the left edge),
|
// Inline start-offset (distance from wall start to the left edge),
|
||||||
@@ -359,6 +387,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.x)}
|
label={formatM(rect.x)}
|
||||||
offset={-6}
|
offset={-6}
|
||||||
horizontal
|
horizontal
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -381,6 +411,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.height)}
|
label={formatM(rect.height)}
|
||||||
offset={-32}
|
offset={-32}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
if (rect.y > 0.001) {
|
if (rect.y > 0.001) {
|
||||||
@@ -395,6 +427,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.y)}
|
label={formatM(rect.y)}
|
||||||
offset={-46}
|
offset={-46}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -410,6 +444,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.height)}
|
label={formatM(rect.height)}
|
||||||
offset={-14}
|
offset={-14}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// Inline elevation (distance from floor to the bottom of the item),
|
// Inline elevation (distance from floor to the bottom of the item),
|
||||||
@@ -427,6 +463,8 @@ export function ProjectionMeasurements({
|
|||||||
label={formatM(rect.y)}
|
label={formatM(rect.y)}
|
||||||
offset={6}
|
offset={6}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
|
lineColor={colors.dimensionLine}
|
||||||
|
textColor={colors.dimensionText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
|||||||
globalFurnitureOpacity,
|
globalFurnitureOpacity,
|
||||||
wallHeight: room.wallHeight,
|
wallHeight: room.wallHeight,
|
||||||
plinthHeight: room.plinthHeight,
|
plinthHeight: room.plinthHeight,
|
||||||
|
stretchCeilingOffset: layerVisibility.stretchCeiling ? room.stretchCeilingOffset : 0,
|
||||||
outletWidth: room.outletWidth,
|
outletWidth: room.outletWidth,
|
||||||
outletHeight: room.outletHeight,
|
outletHeight: room.outletHeight,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Group, Rect, Line } from 'react-konva';
|
import { Group, Rect, Line } from 'react-konva';
|
||||||
import type { ProjectedOpening } from '../utils/projectionMapping';
|
import type { ProjectedOpening } from '../utils/projectionMapping';
|
||||||
import { projectionToPixel } from '../utils/projectionMapping';
|
import { projectionToPixel } from '../utils/projectionMapping';
|
||||||
|
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||||
|
|
||||||
interface ProjectionWindowProps {
|
interface ProjectionWindowProps {
|
||||||
readonly projected: ProjectedOpening;
|
readonly projected: ProjectedOpening;
|
||||||
@@ -26,6 +27,7 @@ export function ProjectionWindow({
|
|||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
}: ProjectionWindowProps) {
|
}: ProjectionWindowProps) {
|
||||||
|
const colors = useCanvasColors();
|
||||||
const { rect, opening } = projected;
|
const { rect, opening } = projected;
|
||||||
|
|
||||||
const displayX = isDragging && dragAlongWall != null
|
const displayX = isDragging && dragAlongWall != null
|
||||||
@@ -54,7 +56,7 @@ export function ProjectionWindow({
|
|||||||
y={topLeft.y - 2}
|
y={topLeft.y - 2}
|
||||||
width={pxWidth + 4}
|
width={pxWidth + 4}
|
||||||
height={pxHeight + 4}
|
height={pxHeight + 4}
|
||||||
stroke="#2563eb"
|
stroke={colors.selectedStroke}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
dash={[3, 3]}
|
dash={[3, 3]}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
@@ -67,7 +69,7 @@ export function ProjectionWindow({
|
|||||||
width={pxWidth}
|
width={pxWidth}
|
||||||
height={pxHeight}
|
height={pxHeight}
|
||||||
fill="#dbeafe"
|
fill="#dbeafe"
|
||||||
stroke={isSelected ? '#2563eb' : '#3b82f6'}
|
stroke={isSelected ? colors.selectedStroke : '#3b82f6'}
|
||||||
strokeWidth={isSelected ? 2.5 : 1.5}
|
strokeWidth={isSelected ? 2.5 : 1.5}
|
||||||
/>
|
/>
|
||||||
{/* Glass pane (inner rectangle) */}
|
{/* Glass pane (inner rectangle) */}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ProjectionElectrical } from './ProjectionElectrical';
|
|||||||
import { ProjectionFurniture } from './ProjectionFurniture';
|
import { ProjectionFurniture } from './ProjectionFurniture';
|
||||||
import { ProjectionMeasurements } from './ProjectionMeasurements';
|
import { ProjectionMeasurements } from './ProjectionMeasurements';
|
||||||
import type { EditorToolType } from '../types';
|
import type { EditorToolType } from '../types';
|
||||||
|
import { useCanvasColors } from '../utils/canvasThemeColors';
|
||||||
|
|
||||||
interface WallProjectionViewProps {
|
interface WallProjectionViewProps {
|
||||||
readonly wall: Wall;
|
readonly wall: Wall;
|
||||||
@@ -32,6 +33,8 @@ interface WallProjectionViewProps {
|
|||||||
readonly showMeasurements?: boolean;
|
readonly showMeasurements?: boolean;
|
||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
readonly plinthHeight: number;
|
readonly plinthHeight: number;
|
||||||
|
/** Distance (m) the stretch ceiling hangs below the structural ceiling. */
|
||||||
|
readonly stretchCeilingOffset?: number;
|
||||||
readonly outletWidth?: number;
|
readonly outletWidth?: number;
|
||||||
readonly outletHeight?: number;
|
readonly outletHeight?: number;
|
||||||
readonly selectedIds: ReadonlySet<string>;
|
readonly selectedIds: ReadonlySet<string>;
|
||||||
@@ -51,8 +54,7 @@ interface WallProjectionViewProps {
|
|||||||
|
|
||||||
const PADDING = 40;
|
const PADDING = 40;
|
||||||
const PLINTH_COLOR = '#8b7355';
|
const PLINTH_COLOR = '#8b7355';
|
||||||
const WALL_FILL = '#f8fafc';
|
// WALL_FILL and WALL_STROKE now come from useCanvasColors().
|
||||||
const WALL_STROKE = '#334155';
|
|
||||||
const FLOOR_COLOR = '#94a3b8';
|
const FLOOR_COLOR = '#94a3b8';
|
||||||
const CEILING_COLOR = '#94a3b8';
|
const CEILING_COLOR = '#94a3b8';
|
||||||
|
|
||||||
@@ -88,6 +90,7 @@ export function WallProjectionView({
|
|||||||
showMeasurements = true,
|
showMeasurements = true,
|
||||||
wallHeight,
|
wallHeight,
|
||||||
plinthHeight,
|
plinthHeight,
|
||||||
|
stretchCeilingOffset = 0,
|
||||||
outletWidth,
|
outletWidth,
|
||||||
outletHeight,
|
outletHeight,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -105,6 +108,7 @@ export function WallProjectionView({
|
|||||||
selectedElectricalType,
|
selectedElectricalType,
|
||||||
}: WallProjectionViewProps) {
|
}: WallProjectionViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useCanvasColors();
|
||||||
const stageRef = useRef<Konva.Stage | null>(null);
|
const stageRef = useRef<Konva.Stage | null>(null);
|
||||||
|
|
||||||
// Expose stage ref to parent for export
|
// Expose stage ref to parent for export
|
||||||
@@ -549,7 +553,7 @@ export function WallProjectionView({
|
|||||||
y={0}
|
y={0}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
fill={isHighlighted ? '#eff6ff' : '#ffffff'}
|
fill={isHighlighted ? colors.canvasBgHighlight : colors.canvasBg}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pan group: all content shifts with viewPan */}
|
{/* Pan group: all content shifts with viewPan */}
|
||||||
@@ -574,7 +578,7 @@ export function WallProjectionView({
|
|||||||
<Line
|
<Line
|
||||||
key={`gv-${w}`}
|
key={`gv-${w}`}
|
||||||
points={[px, PADDING, px, PADDING + wallHeight * effectiveScale]}
|
points={[px, PADDING, px, PADDING + wallHeight * effectiveScale]}
|
||||||
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
|
stroke={isMajor ? colors.gridLineMajor : colors.gridLine}
|
||||||
strokeWidth={isMajor ? 0.8 : 0.4}
|
strokeWidth={isMajor ? 0.8 : 0.4}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>,
|
/>,
|
||||||
@@ -591,7 +595,7 @@ export function WallProjectionView({
|
|||||||
<Line
|
<Line
|
||||||
key={`gh-${h}`}
|
key={`gh-${h}`}
|
||||||
points={[PADDING, py, PADDING + wallLen * effectiveScale, py]}
|
points={[PADDING, py, PADDING + wallLen * effectiveScale, py]}
|
||||||
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
|
stroke={isMajor ? colors.gridLineMajor : colors.gridLine}
|
||||||
strokeWidth={isMajor ? 0.8 : 0.4}
|
strokeWidth={isMajor ? 0.8 : 0.4}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>,
|
/>,
|
||||||
@@ -612,7 +616,7 @@ export function WallProjectionView({
|
|||||||
<Line
|
<Line
|
||||||
key={`rt-${w}`}
|
key={`rt-${w}`}
|
||||||
points={[px, py, px, py + 6]}
|
points={[px, py, px, py + 6]}
|
||||||
stroke="#94a3b8"
|
stroke={colors.rulerText}
|
||||||
strokeWidth={0.8}
|
strokeWidth={0.8}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>,
|
/>,
|
||||||
@@ -626,7 +630,7 @@ export function WallProjectionView({
|
|||||||
text={`${w}`}
|
text={`${w}`}
|
||||||
align="center"
|
align="center"
|
||||||
fontSize={8}
|
fontSize={8}
|
||||||
fill="#94a3b8"
|
fill={colors.rulerText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -643,7 +647,7 @@ export function WallProjectionView({
|
|||||||
<Line
|
<Line
|
||||||
key={`ht-${h}`}
|
key={`ht-${h}`}
|
||||||
points={[PADDING - 6, py, PADDING, py]}
|
points={[PADDING - 6, py, PADDING, py]}
|
||||||
stroke="#94a3b8"
|
stroke={colors.rulerText}
|
||||||
strokeWidth={0.8}
|
strokeWidth={0.8}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>,
|
/>,
|
||||||
@@ -657,7 +661,7 @@ export function WallProjectionView({
|
|||||||
text={`${h}`}
|
text={`${h}`}
|
||||||
align="right"
|
align="right"
|
||||||
fontSize={8}
|
fontSize={8}
|
||||||
fill="#94a3b8"
|
fill={colors.rulerText}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -671,8 +675,8 @@ export function WallProjectionView({
|
|||||||
y={topLeft.y}
|
y={topLeft.y}
|
||||||
width={(topRight.x - topLeft.x)}
|
width={(topRight.x - topLeft.x)}
|
||||||
height={(bottomLeft.y - topLeft.y)}
|
height={(bottomLeft.y - topLeft.y)}
|
||||||
fill={WALL_FILL}
|
fill={colors.wallFill}
|
||||||
stroke={isHighlighted ? '#2563eb' : WALL_STROKE}
|
stroke={isHighlighted ? colors.selectedStroke : colors.wallStroke}
|
||||||
strokeWidth={isHighlighted ? 2 : 1.5}
|
strokeWidth={isHighlighted ? 2 : 1.5}
|
||||||
onClick={handleWallBgClick}
|
onClick={handleWallBgClick}
|
||||||
/>
|
/>
|
||||||
@@ -685,6 +689,46 @@ export function WallProjectionView({
|
|||||||
dash={[6, 4]}
|
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 */}
|
{/* Floor line */}
|
||||||
<Line
|
<Line
|
||||||
points={[bottomLeft.x, bottomLeft.y, bottomRight.x, bottomRight.y]}
|
points={[bottomLeft.x, bottomLeft.y, bottomRight.x, bottomRight.y]}
|
||||||
@@ -754,19 +798,25 @@ export function WallProjectionView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Furniture items (rendered first so electrical overlays them) */}
|
{/* Furniture items (rendered first so electrical overlays them).
|
||||||
{projectedFurniture.map((pf) => (
|
When global opacity is effectively zero the items are invisible,
|
||||||
<ProjectionFurniture
|
so don't wire click handlers — clicks should fall through to
|
||||||
key={pf.item.id}
|
whatever is underneath (wall background, electrical, …). */}
|
||||||
projected={pf}
|
{projectedFurniture.map((pf) => {
|
||||||
wallHeight={wallHeight}
|
const furnitureInteractive = globalFurnitureOpacity >= 0.05;
|
||||||
scale={effectiveScale}
|
return (
|
||||||
padding={PADDING}
|
<ProjectionFurniture
|
||||||
isSelected={selectedIds.has(pf.item.id)}
|
key={pf.item.id}
|
||||||
globalOpacity={globalFurnitureOpacity}
|
projected={pf}
|
||||||
onClick={() => onSelectElement(pf.item.id)}
|
wallHeight={wallHeight}
|
||||||
/>
|
scale={effectiveScale}
|
||||||
))}
|
padding={PADDING}
|
||||||
|
isSelected={selectedIds.has(pf.item.id)}
|
||||||
|
globalOpacity={globalFurnitureOpacity}
|
||||||
|
onClick={furnitureInteractive ? () => onSelectElement(pf.item.id) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Electrical items (on top of furniture) */}
|
{/* Electrical items (on top of furniture) */}
|
||||||
{projectedElectrical.map((pe) => {
|
{projectedElectrical.map((pe) => {
|
||||||
|
|||||||
@@ -38,3 +38,53 @@ export function getElectricalVariant(metadata: Record<string, unknown> | null):
|
|||||||
}
|
}
|
||||||
return 'single';
|
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 { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
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 { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry';
|
||||||
|
import { getWallLightStyle, getWallLightCordLength, getCeilingLampSize, getOutletDirection } from '../symbols/electrical';
|
||||||
|
|
||||||
interface ElectricalMeshWithHeightProps {
|
interface ElectricalMeshWithHeightProps {
|
||||||
readonly item: ElectricalItem;
|
readonly item: ElectricalItem;
|
||||||
@@ -32,12 +33,16 @@ function outletAnchorOffset(
|
|||||||
outletWidth: number,
|
outletWidth: number,
|
||||||
outletHeight: number,
|
outletHeight: number,
|
||||||
): { readonly cx: number; readonly cy: 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 h = item.positionAnchor.horizontal;
|
||||||
const v = item.positionAnchor.vertical;
|
const v = item.positionAnchor.vertical;
|
||||||
const cx = h === 'left' ? totalWidth / 2 : h === 'right' ? -totalWidth / 2 : 0;
|
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).
|
// 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 };
|
return { cx, cy };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +80,7 @@ function OutletMesh({
|
|||||||
outletHeight,
|
outletHeight,
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
|
isVertical,
|
||||||
}: {
|
}: {
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
readonly count: number;
|
readonly count: number;
|
||||||
@@ -82,6 +88,7 @@ function OutletMesh({
|
|||||||
readonly outletHeight: number;
|
readonly outletHeight: number;
|
||||||
readonly centerX: number;
|
readonly centerX: number;
|
||||||
readonly centerY: number;
|
readonly centerY: number;
|
||||||
|
readonly isVertical: boolean;
|
||||||
}) {
|
}) {
|
||||||
const safeCount = Math.max(1, Math.round(count));
|
const safeCount = Math.max(1, Math.round(count));
|
||||||
// Depth into the wall: stays small, just enough to be visible.
|
// Depth into the wall: stays small, just enough to be visible.
|
||||||
@@ -89,10 +96,11 @@ function OutletMesh({
|
|||||||
return (
|
return (
|
||||||
<group position={[centerX, centerY, 0]}>
|
<group position={[centerX, centerY, 0]}>
|
||||||
{Array.from({ length: safeCount }).map((_, i) => {
|
{Array.from({ length: safeCount }).map((_, i) => {
|
||||||
// Center index 0..N-1 around 0 along local x.
|
// Center index 0..N-1 around 0 along the layout axis.
|
||||||
const localX = (i - (safeCount - 1) / 2) * outletWidth;
|
const localX = isVertical ? 0 : (i - (safeCount - 1) / 2) * outletWidth;
|
||||||
|
const localY = isVertical ? (i - (safeCount - 1) / 2) * outletHeight : 0;
|
||||||
return (
|
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]} />
|
<boxGeometry args={[outletWidth * 0.95, outletHeight * 0.95, depth]} />
|
||||||
<meshStandardMaterial color={color} roughness={0.3} />
|
<meshStandardMaterial color={color} roughness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -129,18 +137,28 @@ function JunctionBoxMesh({ color }: { readonly color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ceiling light: disc or sphere hanging from ceiling */
|
/** Ceiling light: disc or sphere hanging from ceiling, scalable by lampSize (diameter in meters). */
|
||||||
function CeilingLightMesh({ color, wallHeight }: { readonly color: string; readonly wallHeight: number }) {
|
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 (
|
return (
|
||||||
<group position={[0, wallHeight - 0.05, 0]}>
|
<group position={[0, wallHeight - 0.05, 0]}>
|
||||||
{/* Canopy */}
|
{/* Canopy */}
|
||||||
<mesh castShadow>
|
<mesh castShadow>
|
||||||
<cylinderGeometry args={[0.05, 0.05, 0.02, 16]} />
|
<cylinderGeometry args={[canopyRadius, canopyRadius, canopyHeight, 16]} />
|
||||||
<meshStandardMaterial color="#666666" roughness={0.4} />
|
<meshStandardMaterial color="#666666" roughness={0.4} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Shade / bulb */}
|
{/* Shade / bulb */}
|
||||||
<mesh position={[0, -0.12, 0]}>
|
<mesh position={[0, -dropDistance, 0]}>
|
||||||
<sphereGeometry args={[0.08, 16, 16]} />
|
<sphereGeometry args={[radius, 16, 16]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={color}
|
color={color}
|
||||||
emissive={color}
|
emissive={color}
|
||||||
@@ -152,18 +170,22 @@ function CeilingLightMesh({ color, wallHeight }: { readonly color: string; reado
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wall light: half-sphere attached to wall */
|
/** Classic wall light: half-sphere attached to wall, scalable by lampSize (diameter in meters). */
|
||||||
function WallLightMesh({ color }: { readonly color: string }) {
|
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 (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Base plate */}
|
{/* Base plate */}
|
||||||
<mesh castShadow>
|
<mesh castShadow>
|
||||||
<boxGeometry args={[0.10, 0.10, 0.02]} />
|
<boxGeometry args={[plateSize, plateSize, 0.02]} />
|
||||||
<meshStandardMaterial color="#666666" roughness={0.4} />
|
<meshStandardMaterial color="#666666" roughness={0.4} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Half sphere shade */}
|
{/* Half sphere shade */}
|
||||||
<mesh position={[0, 0, 0.03]}>
|
<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
|
<meshStandardMaterial
|
||||||
color={color}
|
color={color}
|
||||||
emissive={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 */
|
/** Cable route: small orange marker */
|
||||||
function CableRouteMesh({ color }: { readonly color: string }) {
|
function CableRouteMesh({ color }: { readonly color: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -296,13 +495,23 @@ export function ElectricalMeshWithHeight({
|
|||||||
outletHeight={outletHeight}
|
outletHeight={outletHeight}
|
||||||
centerX={offset.cx}
|
centerX={offset.cx}
|
||||||
centerY={offset.cy}
|
centerY={offset.cy}
|
||||||
|
isVertical={getOutletDirection(item.metadata) === 'vertical'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{item.type === 'SWITCH' && <SwitchMesh color={color} />}
|
{item.type === 'SWITCH' && <SwitchMesh color={color} />}
|
||||||
{item.type === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
|
{item.type === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
|
||||||
{item.type === 'LIGHT_CEILING' && <CeilingLightMesh color={color} wallHeight={wallHeight} />}
|
{item.type === 'LIGHT_CEILING' && (
|
||||||
{item.type === 'LIGHT_WALL' && <WallLightMesh color={color} />}
|
<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} />}
|
{item.type === 'CABLE_ROUTE' && <CableRouteMesh color={color} />}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ interface FloorCeilingProps {
|
|||||||
readonly shape: readonly Point[];
|
readonly shape: readonly Point[];
|
||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
readonly floorType?: FloorType;
|
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,23 +55,120 @@ function buildFloorGeometry(shape: readonly Point[], tileMeters: number): THREE.
|
|||||||
return geometry;
|
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 pbr = useMemo(() => getFloorPbr(floorType), [floorType]);
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildFloorGeometry(shape, pbr.tileMeters),
|
() => buildFloorGeometry(shape, pbr.tileMeters),
|
||||||
[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;
|
if (!geometry) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<>
|
||||||
geometry={geometry}
|
<mesh
|
||||||
material={pbr.material}
|
geometry={geometry}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
material={pbr.material}
|
||||||
scale={[1, -1, 1]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
position={[0, 0, 0]}
|
scale={[1, -1, 1]}
|
||||||
receiveShadow
|
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 { useEffect, useMemo, useRef } from 'react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared';
|
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 { 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
|
* 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
|
// 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 ──
|
// ── Shared geometries for common shapes ──
|
||||||
|
|
||||||
const legGeometry = new THREE.CylinderGeometry(LEG_RADIUS, LEG_RADIUS, 1, LEG_SEGMENTS);
|
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 frameHeight = item.height * 0.3;
|
||||||
const headboardHeight = item.height;
|
const headboardHeight = item.height;
|
||||||
const mattressMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]);
|
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 (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Frame */}
|
{/* 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]} />
|
<boxGeometry args={[item.width, frameHeight, item.depth]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Mattress */}
|
{/* 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]} />
|
<boxGeometry args={[item.width * 0.95, mattressHeight, item.depth * 0.95]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Headboard */}
|
{/* 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]} />
|
<boxGeometry args={[item.width, headboardHeight, 0.04]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
@@ -125,7 +160,7 @@ function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
|
|||||||
const topThickness = 0.04;
|
const topThickness = 0.04;
|
||||||
const legHeight = item.height - topThickness;
|
const legHeight = item.height - topThickness;
|
||||||
const inset = 0.05;
|
const inset = 0.05;
|
||||||
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
|
const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -150,7 +185,7 @@ function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
|
|||||||
|
|
||||||
/** Wardrobe: tall box with slight door line */
|
/** Wardrobe: tall box with slight door line */
|
||||||
function WardrobeMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
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 (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -207,7 +242,7 @@ function TableMesh({ item, color }: { readonly item: FurnitureItem; readonly col
|
|||||||
const topThickness = 0.03;
|
const topThickness = 0.03;
|
||||||
const legHeight = item.height - topThickness;
|
const legHeight = item.height - topThickness;
|
||||||
const inset = 0.05;
|
const inset = 0.05;
|
||||||
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
|
const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -234,7 +269,7 @@ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly col
|
|||||||
const seatThickness = 0.03;
|
const seatThickness = 0.03;
|
||||||
const legHeight = seatHeight - seatThickness;
|
const legHeight = seatHeight - seatThickness;
|
||||||
const inset = 0.03;
|
const inset = 0.03;
|
||||||
const chairMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
|
const chairMaterial = useSurfaceMaterial(item, color, 0.6, item.width, item.depth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -263,7 +298,7 @@ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly col
|
|||||||
|
|
||||||
/** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */
|
/** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */
|
||||||
function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
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 (
|
return (
|
||||||
<mesh position={[0, item.height / 2, 0]} castShadow material={material}>
|
<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 hasBackPanel = typeof hasBackPanelRaw === 'boolean' ? hasBackPanelRaw : true;
|
||||||
|
|
||||||
const panelThickness = 0.02;
|
const panelThickness = 0.02;
|
||||||
const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
|
const material = useSurfaceMaterial(item, color, 0.6, item.width, item.height);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -589,7 +624,7 @@ function CurtainMesh({ item, color: _defaultColor }: { readonly item: FurnitureI
|
|||||||
* looking sparse.
|
* looking sparse.
|
||||||
*/
|
*/
|
||||||
function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
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 mattressMaterial = useMemo(() => getFurnitureMaterial('#f4eadf', 0.9), []);
|
||||||
|
|
||||||
const mattressThick = 0.08;
|
const mattressThick = 0.08;
|
||||||
@@ -710,7 +745,7 @@ function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
|
|||||||
* upright panel.
|
* upright panel.
|
||||||
*/
|
*/
|
||||||
function DressingTableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
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 mirrorMaterial = useMemo(() => getFurnitureMaterial('#b8d0d8', 0.05), []);
|
||||||
const mirrorFrameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]);
|
const mirrorFrameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]);
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function Room3DView() {
|
|||||||
}, [walls]);
|
}, [walls]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<CameraPresetsUI
|
<CameraPresetsUI
|
||||||
shape={shape}
|
shape={shape}
|
||||||
wallHeight={wallHeight}
|
wallHeight={wallHeight}
|
||||||
@@ -266,7 +266,14 @@ export function Room3DView() {
|
|||||||
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
|
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
|
||||||
|
|
||||||
{/* Floor */}
|
{/* 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) */}
|
{/* Walls (hide the one facing the camera) */}
|
||||||
{layerVisibility.walls && walls.map((wall) => (
|
{layerVisibility.walls && walls.map((wall) => (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
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
|
* 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_MEDIUM: 1.4,
|
||||||
WOOD_DARK: 1.4,
|
WOOD_DARK: 1.4,
|
||||||
WOOD_HERRINGBONE: 1.0,
|
WOOD_HERRINGBONE: 1.0,
|
||||||
|
OAK_NATURAL: 1.4,
|
||||||
TILE_WHITE: 1.0,
|
TILE_WHITE: 1.0,
|
||||||
TILE_GRAY: 1.0,
|
TILE_GRAY: 1.0,
|
||||||
LAMINATE: 1.4,
|
LAMINATE: 1.4,
|
||||||
@@ -107,6 +108,42 @@ export function getWallPbr(finish: Exclude<WallFinish, 'PAINT'>): PbrSet {
|
|||||||
return entry;
|
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
|
* 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
|
* once per `tileMeters`. Apply this on the geometry side rather than the
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export interface LayerVisibility {
|
|||||||
readonly furniture: boolean;
|
readonly furniture: boolean;
|
||||||
readonly measurements: boolean;
|
readonly measurements: boolean;
|
||||||
readonly annotations: 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 {
|
export interface EditorState {
|
||||||
@@ -75,7 +82,7 @@ export interface EditorCommand {
|
|||||||
|
|
||||||
export type EditorAction =
|
export type EditorAction =
|
||||||
| { readonly type: 'SET_ROOM'; readonly room: RoomFull }
|
| { 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: 'SET_WALLS'; readonly walls: readonly Wall[] }
|
||||||
| { readonly type: 'UPDATE_WALL'; readonly wall: Wall }
|
| { readonly type: 'UPDATE_WALL'; readonly wall: Wall }
|
||||||
| { readonly type: 'ADD_OPENING'; readonly opening: WallOpening }
|
| { 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)
|
wallHeight Float @default(2.7)
|
||||||
plinthHeight Float @default(0.06)
|
plinthHeight Float @default(0.06)
|
||||||
plinthThickness Float @default(0.01)
|
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)
|
order Int @default(0)
|
||||||
posX Float @default(0)
|
posX Float @default(0)
|
||||||
posY Float @default(0)
|
posY Float @default(0)
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|||||||
wallHeight: input.wallHeight ?? 2.7,
|
wallHeight: input.wallHeight ?? 2.7,
|
||||||
plinthHeight: input.plinthHeight ?? 0.06,
|
plinthHeight: input.plinthHeight ?? 0.06,
|
||||||
plinthThickness: input.plinthThickness ?? 0.01,
|
plinthThickness: input.plinthThickness ?? 0.01,
|
||||||
|
...(input.stretchCeilingOffset !== undefined && {
|
||||||
|
stretchCeilingOffset: input.stretchCeilingOffset,
|
||||||
|
}),
|
||||||
order: input.order ?? 0,
|
order: input.order ?? 0,
|
||||||
// posX/posY added in schema migration; client regeneration required
|
// posX/posY added in schema migration; client regeneration required
|
||||||
...(input.posX !== undefined && { posX: input.posX }),
|
...(input.posX !== undefined && { posX: input.posX }),
|
||||||
@@ -156,6 +159,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|||||||
...(input.wallHeight !== undefined && { wallHeight: input.wallHeight }),
|
...(input.wallHeight !== undefined && { wallHeight: input.wallHeight }),
|
||||||
...(input.plinthHeight !== undefined && { plinthHeight: input.plinthHeight }),
|
...(input.plinthHeight !== undefined && { plinthHeight: input.plinthHeight }),
|
||||||
...(input.plinthThickness !== undefined && { plinthThickness: input.plinthThickness }),
|
...(input.plinthThickness !== undefined && { plinthThickness: input.plinthThickness }),
|
||||||
|
...(input.stretchCeilingOffset !== undefined && {
|
||||||
|
stretchCeilingOffset: input.stretchCeilingOffset,
|
||||||
|
}),
|
||||||
...(input.order !== undefined && { order: input.order }),
|
...(input.order !== undefined && { order: input.order }),
|
||||||
...(input.posX !== undefined && { posX: input.posX }),
|
...(input.posX !== undefined && { posX: input.posX }),
|
||||||
...(input.posY !== undefined && { posY: input.posY }),
|
...(input.posY !== undefined && { posY: input.posY }),
|
||||||
@@ -237,6 +243,8 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|||||||
wallHeight: source.wallHeight,
|
wallHeight: source.wallHeight,
|
||||||
plinthHeight: source.plinthHeight,
|
plinthHeight: source.plinthHeight,
|
||||||
plinthThickness: source.plinthThickness,
|
plinthThickness: source.plinthThickness,
|
||||||
|
stretchCeilingOffset:
|
||||||
|
(((source as Record<string, unknown>).stretchCeilingOffset as number | undefined) ?? 0),
|
||||||
order: nextOrder,
|
order: nextOrder,
|
||||||
posX: source.posX,
|
posX: source.posX,
|
||||||
posY: source.posY,
|
posY: source.posY,
|
||||||
@@ -376,6 +384,7 @@ function toRoomResponse(room: {
|
|||||||
wallHeight: number;
|
wallHeight: number;
|
||||||
plinthHeight: number;
|
plinthHeight: number;
|
||||||
plinthThickness: number;
|
plinthThickness: number;
|
||||||
|
stretchCeilingOffset?: number | null;
|
||||||
order: number;
|
order: number;
|
||||||
posX?: number | null;
|
posX?: number | null;
|
||||||
posY?: number | null;
|
posY?: number | null;
|
||||||
@@ -394,6 +403,7 @@ function toRoomResponse(room: {
|
|||||||
wallHeight: room.wallHeight,
|
wallHeight: room.wallHeight,
|
||||||
plinthHeight: room.plinthHeight,
|
plinthHeight: room.plinthHeight,
|
||||||
plinthThickness: room.plinthThickness,
|
plinthThickness: room.plinthThickness,
|
||||||
|
stretchCeilingOffset: room.stretchCeilingOffset ?? 0,
|
||||||
order: room.order,
|
order: room.order,
|
||||||
posX: room.posX ?? 0,
|
posX: room.posX ?? 0,
|
||||||
posY: room.posY ?? 0,
|
posY: room.posY ?? 0,
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export type {
|
|||||||
VerticalAnchor,
|
VerticalAnchor,
|
||||||
PositionAnchor,
|
PositionAnchor,
|
||||||
ElectricalType,
|
ElectricalType,
|
||||||
|
OutletDirection,
|
||||||
|
WallLightStyle,
|
||||||
|
FurnitureTexture,
|
||||||
ElectricalItem,
|
ElectricalItem,
|
||||||
CreateElectricalItemDto,
|
CreateElectricalItemDto,
|
||||||
UpdateElectricalItemDto,
|
UpdateElectricalItemDto,
|
||||||
@@ -62,6 +65,10 @@ export {
|
|||||||
OPENING_TYPES,
|
OPENING_TYPES,
|
||||||
DOOR_OPEN_DIRECTIONS,
|
DOOR_OPEN_DIRECTIONS,
|
||||||
ELECTRICAL_TYPES,
|
ELECTRICAL_TYPES,
|
||||||
|
OUTLET_DIRECTIONS,
|
||||||
|
WALL_LIGHT_STYLES,
|
||||||
|
FURNITURE_TEXTURES,
|
||||||
|
TEXTURABLE_FURNITURE,
|
||||||
FURNITURE_TYPES,
|
FURNITURE_TYPES,
|
||||||
HORIZONTAL_ANCHORS,
|
HORIZONTAL_ANCHORS,
|
||||||
VERTICAL_ANCHORS,
|
VERTICAL_ANCHORS,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const createRoomSchema = z.object({
|
|||||||
wallHeight: z.number().positive('Wall height must be positive').optional(),
|
wallHeight: z.number().positive('Wall height must be positive').optional(),
|
||||||
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').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(),
|
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(),
|
order: z.number().int().min(0).optional(),
|
||||||
posX: z.number().optional(),
|
posX: z.number().optional(),
|
||||||
posY: 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(),
|
wallHeight: z.number().positive('Wall height must be positive').optional(),
|
||||||
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').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(),
|
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(),
|
order: z.number().int().min(0).optional(),
|
||||||
posX: z.number().optional(),
|
posX: z.number().optional(),
|
||||||
posY: z.number().optional(),
|
posY: z.number().optional(),
|
||||||
|
|||||||
@@ -224,6 +224,17 @@ export const ELECTRICAL_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type ElectricalType = (typeof ELECTRICAL_TYPES)[number];
|
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 {
|
export interface ElectricalItem {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly roomId: string;
|
readonly roomId: string;
|
||||||
@@ -277,6 +288,35 @@ export interface UpdateElectricalItemDto {
|
|||||||
readonly metadata?: Record<string, unknown> | null;
|
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 ──
|
// ── FurnitureItem ──
|
||||||
|
|
||||||
export const FURNITURE_TYPES = [
|
export const FURNITURE_TYPES = [
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const FLOOR_TYPES = [
|
|||||||
'WOOD_MEDIUM',
|
'WOOD_MEDIUM',
|
||||||
'WOOD_DARK',
|
'WOOD_DARK',
|
||||||
'WOOD_HERRINGBONE',
|
'WOOD_HERRINGBONE',
|
||||||
|
'OAK_NATURAL',
|
||||||
'TILE_WHITE',
|
'TILE_WHITE',
|
||||||
'TILE_GRAY',
|
'TILE_GRAY',
|
||||||
'LAMINATE',
|
'LAMINATE',
|
||||||
@@ -50,6 +51,13 @@ export interface Room {
|
|||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
readonly plinthHeight: number;
|
readonly plinthHeight: number;
|
||||||
readonly plinthThickness: 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 order: number;
|
||||||
readonly posX: number;
|
readonly posX: number;
|
||||||
readonly posY: number;
|
readonly posY: number;
|
||||||
@@ -80,6 +88,7 @@ export interface CreateRoomDto {
|
|||||||
readonly wallHeight?: number;
|
readonly wallHeight?: number;
|
||||||
readonly plinthHeight?: number;
|
readonly plinthHeight?: number;
|
||||||
readonly plinthThickness?: number;
|
readonly plinthThickness?: number;
|
||||||
|
readonly stretchCeilingOffset?: number;
|
||||||
readonly order?: number;
|
readonly order?: number;
|
||||||
readonly posX?: number;
|
readonly posX?: number;
|
||||||
readonly posY?: number;
|
readonly posY?: number;
|
||||||
@@ -98,6 +107,7 @@ export interface UpdateRoomDto {
|
|||||||
readonly wallHeight?: number;
|
readonly wallHeight?: number;
|
||||||
readonly plinthHeight?: number;
|
readonly plinthHeight?: number;
|
||||||
readonly plinthThickness?: number;
|
readonly plinthThickness?: number;
|
||||||
|
readonly stretchCeilingOffset?: number;
|
||||||
readonly order?: number;
|
readonly order?: number;
|
||||||
readonly posX?: number;
|
readonly posX?: number;
|
||||||
readonly posY?: number;
|
readonly posY?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user