feat(floorplan): add real-time 3D preview to floor plan editor

- Add FloorplanPreviewView with live isometric room preview
- Add FloorplanHeightSelector component for height picking
- Refactor FloorplanOptionsView for cleaner layout
- Update FloorplanEditorContext with preview state management
- Improve FloorplanCanvasView rendering
This commit is contained in:
simoleo89
2026-03-20 14:18:45 +01:00
parent 1943337e77
commit 30bea6fca5
6 changed files with 647 additions and 257 deletions
@@ -1,19 +1,22 @@
import { AddLinkEventTracker, FloorHeightMapEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FC, useCallback, useEffect, useState } from 'react';
import { FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
import { Button, ButtonGroup, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { Button, ButtonGroup, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMessageEvent, useNitroEvent } from '../../hooks';
import { FloorplanEditorContextProvider } from './FloorplanEditorContext';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
import { IFloorplanSettings } from '@nitrots/nitro-renderer';
import { IVisualizationSettings } from '@nitrots/nitro-renderer';
import { convertNumbersForSaving, convertSettingToNumber } from '@nitrots/nitro-renderer';
import { convertNumbersForSaving, convertSettingToNumber, FloorAction, HEIGHT_SCHEME } from '@nitrots/nitro-renderer';
import { FloorplanCanvasView } from './views/FloorplanCanvasView';
import { FloorplanImportExportView } from './views/FloorplanImportExportView';
import { FloorplanOptionsView } from './views/FloorplanOptionsView';
import { FloorplanHeightSelector } from './views/FloorplanHeightSelector';
import { FloorplanPreviewView } from './views/FloorplanPreviewView';
type ScrollDirection = 'up' | 'down' | 'left' | 'right';
const MIN_WALL_HEIGHT = 0;
const MAX_WALL_HEIGHT = 16;
export const FloorplanEditorView: FC<{}> = props =>
{
@@ -34,7 +37,65 @@ export const FloorplanEditorView: FC<{}> = props =>
thicknessWall: 1,
thicknessFloor: 1
});
const [ canvasScrollHandler, setCanvasScrollHandler ] = useState<((direction: ScrollDirection) => void) | null>(null);
const [ floorHeight, setFloorHeight ] = useState(0);
const [ floorAction, setFloorAction ] = useState(FloorAction.SET);
const [ tilemapVersion, setTilemapVersion ] = useState(0);
const [ areaInfo, setAreaInfo ] = useState({ total: 0, walkable: 0 });
const calculateArea = useCallback(() =>
{
const tilemap = FloorplanEditor.instance.tilemap;
if(!tilemap || tilemap.length === 0)
{
setAreaInfo({ total: 0, walkable: 0 });
return;
}
let total = 0;
let walkable = 0;
for(let y = 0; y < tilemap.length; y++)
{
if(!tilemap[y]) continue;
for(let x = 0; x < tilemap[y].length; x++)
{
if(!tilemap[y][x] || tilemap[y][x].height === 'x') continue;
total++;
if(!tilemap[y][x].isBlocked) walkable++;
}
}
setAreaInfo({ total, walkable });
}, []);
// sync floorHeight/floorAction changes to the FloorplanEditor instance
useEffect(() =>
{
FloorplanEditor.instance.actionSettings.currentAction = floorAction;
FloorplanEditor.instance.actionSettings.currentHeight = floorHeight.toString(36);
}, [ floorHeight, floorAction ]);
// register onTilemapChange callback
useEffect(() =>
{
if(!isVisible) return;
FloorplanEditor.instance.onTilemapChange = () =>
{
setTilemapVersion(prev => prev + 1);
calculateArea();
};
return () =>
{
FloorplanEditor.instance.onTilemapChange = null;
};
}, [ isVisible, calculateArea ]);
const saveFloorChanges = () =>
{
@@ -47,16 +108,50 @@ export const FloorplanEditorView: FC<{}> = props =>
convertNumbersForSaving(visualizationSettings.thicknessFloor),
(visualizationSettings.wallHeight - 1)
));
}
};
const revertChanges = () =>
{
setVisualizationSettings({ wallHeight: originalFloorplanSettings.wallHeight, thicknessWall: originalFloorplanSettings.thicknessWall, thicknessFloor: originalFloorplanSettings.thicknessFloor, entryPointDir: originalFloorplanSettings.entryPointDir });
FloorplanEditor.instance.doorLocation = { x: originalFloorplanSettings.entryPoint[0], y: originalFloorplanSettings.entryPoint[1] };
FloorplanEditor.instance.setTilemap(originalFloorplanSettings.tilemap, originalFloorplanSettings.reservedTiles);
FloorplanEditor.instance.renderTiles();
}
};
const onWallHeightChange = (value: number) =>
{
if(isNaN(value) || (value <= 0)) value = MIN_WALL_HEIGHT;
if(value > MAX_WALL_HEIGHT) value = MAX_WALL_HEIGHT;
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.wallHeight = value;
return newValue;
});
};
const increaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight + 1);
if(height > MAX_WALL_HEIGHT) height = MAX_WALL_HEIGHT;
onWallHeightChange(height);
};
const decreaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight - 1);
if(height <= 0) height = MIN_WALL_HEIGHT;
onWallHeightChange(height);
};
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, event => setIsVisible(false));
@@ -117,7 +212,7 @@ export const FloorplanEditorView: FC<{}> = props =>
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
@@ -140,17 +235,42 @@ export const FloorplanEditorView: FC<{}> = props =>
}, []);
return (
<FloorplanEditorContextProvider value={ { originalFloorplanSettings: originalFloorplanSettings, setOriginalFloorplanSettings: setOriginalFloorplanSettings, visualizationSettings: visualizationSettings, setVisualizationSettings: setVisualizationSettings } }>
<FloorplanEditorContextProvider value={ {
originalFloorplanSettings,
setOriginalFloorplanSettings,
visualizationSettings,
setVisualizationSettings,
floorHeight,
setFloorHeight,
floorAction,
setFloorAction,
tilemapVersion,
areaInfo
} }>
{ isVisible &&
<NitroCardView uniqueKey="floorpan-editor" className="w-[760px] h-[500px]" theme="primary-slim">
<NitroCardView uniqueKey="floorpan-editor" className="w-[1100px] h-[600px]" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView overflow="hidden">
<FloorplanOptionsView onCanvasScroll={ direction => canvasScrollHandler && canvasScrollHandler(direction) } />
<FloorplanCanvasView overflow="hidden" setScrollHandler={ setCanvasScrollHandler } />
<NitroCardContentView overflow="hidden" className="flex flex-col">
<FloorplanOptionsView />
<Flex gap={ 2 } className="flex-1 min-h-0">
<FloorplanHeightSelector />
<FloorplanCanvasView overflow="hidden" />
<Column gap={ 2 } className="w-[380px] min-w-[380px]">
<FloorplanPreviewView />
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.editor.wall.height') }</Text>
<FaCaretLeft className="cursor-pointer fa-icon" onClick={ decreaseWallHeight } />
<input type="number" className="form-control form-control-sm w-[49px]" value={ visualizationSettings.wallHeight } onChange={ event => onWallHeightChange(event.target.valueAsNumber) } />
<FaCaretRight className="cursor-pointer fa-icon" onClick={ increaseWallHeight } />
</Flex>
<Text bold small className="text-center">
Area: { areaInfo.total } ({ areaInfo.walkable } caselle)
</Text>
</Column>
</Flex>
<Flex justifyContent="between">
<Button onClick={ revertChanges }>{ LocalizeText('floor.plan.editor.reload') }</Button>
<Button variant="danger" onClick={ revertChanges }>{ LocalizeText('floor.plan.editor.reload') }</Button>
<ButtonGroup>
<Button disabled={ true }>{ LocalizeText('floor.plan.editor.preview') }</Button>
<Button onClick={ event => setImportExportVisible(true) }>{ LocalizeText('floor.plan.editor.import.export') }</Button>
<Button onClick={ saveFloorChanges }>{ LocalizeText('floor.plan.editor.save') }</Button>
</ButtonGroup>
@@ -161,4 +281,4 @@ export const FloorplanEditorView: FC<{}> = props =>
<FloorplanImportExportView onCloseClick={ () => setImportExportVisible(false) } /> }
</FloorplanEditorContextProvider>
);
}
};