Merge pull request #158 from simoleo89/pr/floor-editor-modernization

feat(floorplan-editor): React rewrite + live in-room preview + UX polish
This commit is contained in:
DuckieTM
2026-05-26 13:21:29 +02:00
committed by GitHub
42 changed files with 3812 additions and 996 deletions
@@ -0,0 +1,234 @@
/* @vitest-environment jsdom */
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FloorplanState } from '../../../components/floorplan-editor/state/types';
import {
buildLivePreviewPayload,
LivePreviewPayload,
livePreviewPayloadsEqual,
useFloorplanLiveSync
} from './useFloorplanLiveSync';
// Spy on the renderer hook seam — we hand back a manager that
// records calls to applyFloorModelLocally so we can assert what
// the hook pushed to the room.
const applyMock = vi.fn<(model: string, wallHeight: number, scale: boolean) => boolean>();
const thicknessMock = vi.fn<(roomId: number, wall: number, floor: number) => boolean>();
vi.mock('@nitrots/nitro-renderer', async (importOriginal) =>
{
const actual = await importOriginal<typeof import('@nitrots/nitro-renderer')>();
return {
...actual,
GetRoomMessageHandler: () => ({
applyFloorModelLocally: (model: string, wallHeight: number, scale: boolean) =>
applyMock(model, wallHeight, scale)
}),
GetRoomEngine: () => ({
updateRoomInstancePlaneThickness: (roomId: number, wall: number, floor: number) =>
thicknessMock(roomId, wall, floor)
})
};
});
// Pin the active room id so the engine.updateRoomInstancePlaneThickness
// branch is exercised. -1 is the "no active room" sentinel and skips
// the thickness call.
vi.mock('../../session/useSessionSnapshots', () => ({
useActiveRoomSessionSnapshot: () => ({ roomId: 42 })
}));
const baseState: FloorplanState = {
tiles: [
[ { h: 0, blocked: false }, { h: 0, blocked: false } ],
[ { h: 0, blocked: false }, { h: 0, blocked: false } ]
],
door: { x: 1, y: 1, dir: 2 },
thickness: { wall: 1, floor: 1 },
wallHeight: 1,
brush: { h: 0, action: 'SET' },
selection: new Set(),
squareSelect: false,
lease: { holder: null, me: false, expiresAt: null },
seq: 0
};
const samePayload = (s: FloorplanState): LivePreviewPayload => buildLivePreviewPayload(s);
describe('useFloorplanLiveSync', () =>
{
beforeEach(() =>
{
applyMock.mockReset().mockReturnValue(true);
thicknessMock.mockReset().mockReturnValue(true);
});
afterEach(() =>
{
cleanup();
});
it('does not call the renderer before a baseline is set', () =>
{
renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
expect(applyMock).not.toHaveBeenCalled();
});
it('does not call the renderer when the state equals the baseline', () =>
{
const { result } = renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
expect(applyMock).not.toHaveBeenCalled();
});
it('applies the new floor model locally on every diverging state change', () =>
{
const { result, rerender } = renderHook(
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
{ initialProps: { state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
const next: FloorplanState = {
...baseState,
tiles: [
[ { h: 1, blocked: false }, { h: 0, blocked: false } ],
[ { h: 0, blocked: false }, { h: 0, blocked: false } ]
]
};
rerender({ state: next });
expect(applyMock).toHaveBeenCalledTimes(1);
// The renderer parser takes server-space wallHeight (editor - 1).
expect(applyMock.mock.calls[0][1]).toBe(0);
expect(applyMock.mock.calls[0][2]).toBe(true);
// Same brush/selection change should NOT push another paint.
rerender({ state: { ...next, selection: new Set([ '0,0' ]) } });
expect(applyMock).toHaveBeenCalledTimes(1);
});
it('mirrors the thickness slider into the renderer engine', () =>
{
const { result, rerender } = renderHook(
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
{ initialProps: { state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
rerender({ state: { ...baseState, thickness: { wall: 3, floor: 2 } } });
// Thickness 3 -> 2.0, thickness 2 -> 1.0
const [ roomId, wall, floor ] = thicknessMock.mock.calls[thicknessMock.mock.calls.length - 1];
expect(roomId).toBe(42);
expect(wall).toBe(2);
expect(floor).toBe(1);
});
it('does not call the renderer when disabled', () =>
{
const { result, rerender } = renderHook(
({ enabled, state }: { enabled: boolean; state: FloorplanState }) =>
useFloorplanLiveSync({ enabled, state }),
{ initialProps: { enabled: false, state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
rerender({ enabled: false, state: { ...baseState, wallHeight: 5 } });
expect(applyMock).not.toHaveBeenCalled();
});
it('revert re-applies the baseline to the renderer', () =>
{
const { result, rerender } = renderHook(
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
{ initialProps: { state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
rerender({ state: { ...baseState, wallHeight: 7 } });
// One push from the wallHeight bump.
expect(applyMock).toHaveBeenCalledTimes(1);
act(() =>
{
result.current.revert();
});
expect(applyMock).toHaveBeenCalledTimes(2);
// Revert applies the baseline payload, which has wallHeight=1
// → server-space 0.
expect(applyMock.mock.calls[1][1]).toBe(0);
});
it('revert is a no-op when no baseline has been recorded', () =>
{
const { result } = renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
act(() =>
{
result.current.revert();
});
expect(applyMock).not.toHaveBeenCalled();
});
});
describe('livePreviewPayloadsEqual', () =>
{
const p: LivePreviewPayload = {
tilemap: 'xx\rxx',
doorX: 0,
doorY: 0,
doorDir: 2,
thicknessWall: 1,
thicknessFloor: 1,
wallHeight: 3
};
it('returns true for identical payloads', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p })).toBe(true);
});
it('detects a tilemap diff', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p, tilemap: '00\r00' })).toBe(false);
});
it('detects a wallHeight diff', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p, wallHeight: 4 })).toBe(false);
});
it('detects a door diff', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p, doorDir: 4 })).toBe(false);
});
});
@@ -0,0 +1,170 @@
import { GetRoomEngine, GetRoomMessageHandler } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { serializeTilemap } from '../../../components/floorplan-editor/state/encoding';
import { FloorplanState } from '../../../components/floorplan-editor/state/types';
import { useActiveRoomSessionSnapshot } from '../../session/useSessionSnapshots';
/**
* Client-side live preview for the floor-plan editor.
*
* Every tile / door / thickness / wallHeight change in the editor
* is applied IMMEDIATELY to the 3D room behind the editor card
* via the renderer's local `RoomMessageHandler.applyFloorModelLocally`
* (added in the renderer's `feat/floorplan-live-preview` branch).
* Nothing is sent to the server until the user explicitly clicks
* Save — at that point `FloorplanEditorView` fires the
* `UpdateFloorPropertiesMessageComposer` directly.
*
* Closing the editor without saving leaves the live preview
* in place visually. To restore the pre-edit room, call `revert`
* — it re-applies the baseline payload locally. The next
* `FloorHeightMapEvent` from the server (e.g. on room re-enter)
* also wins and overwrites whatever preview is in place.
*
* Thickness changes additionally call
* `RoomEngine.updateRoomInstancePlaneThickness` for zero-latency
* wall/floor depth feedback (the full geometry rebuild that
* `applyFloorModelLocally` performs already reflects the new
* thickness in its plane data, but the dedicated thickness
* setter is cheaper and updates instantly as a slider is dragged).
*/
export type LivePreviewPayload = {
/** Newline-or-CR-separated tilemap (the renderer parser accepts \r). */
tilemap: string;
doorX: number;
doorY: number;
doorDir: number;
/** Editor-space (0..3). */
thicknessWall: number;
thicknessFloor: number;
/** Editor-space (1..N). Server space is `wallHeight - 1`. */
wallHeight: number;
};
export type UseFloorplanLiveSyncOptions = {
enabled: boolean;
state: FloorplanState;
};
export type UseFloorplanLiveSyncApi = {
/**
* Mark a payload as "currently shown in the room" so subsequent
* state diffs are computed against it. Editors call this on
* every server-driven snapshot push (FloorHeightMapEvent,
* RoomVisualizationSettingsEvent, …).
*/
setBaseline: (payload: LivePreviewPayload) => void;
/**
* Restore the in-room preview to the recorded baseline.
* Use when the user closes the editor without saving.
*/
revert: () => void;
};
const THICKNESS_RENDERER_VALUE: Record<number, number> = {
0: 0.25,
1: 0.5,
2: 1,
3: 2
};
export const buildLivePreviewPayload = (state: FloorplanState): LivePreviewPayload => ({
tilemap: serializeTilemap(state.tiles),
doorX: state.door.x,
doorY: state.door.y,
doorDir: state.door.dir,
thicknessWall: state.thickness.wall,
thicknessFloor: state.thickness.floor,
wallHeight: state.wallHeight
});
export const livePreviewPayloadsEqual = (a: LivePreviewPayload, b: LivePreviewPayload): boolean =>
a.tilemap === b.tilemap
&& a.doorX === b.doorX
&& a.doorY === b.doorY
&& a.doorDir === b.doorDir
&& a.thicknessWall === b.thicknessWall
&& a.thicknessFloor === b.thicknessFloor
&& a.wallHeight === b.wallHeight;
const applyToRenderer = (payload: LivePreviewPayload, roomId: number): boolean =>
{
const handler = GetRoomMessageHandler();
if(!handler || typeof handler.applyFloorModelLocally !== 'function') return false;
const ok = handler.applyFloorModelLocally(payload.tilemap, Math.max(0, (payload.wallHeight | 0) - 1), true);
if(!ok) return false;
if(roomId >= 0)
{
const engine = GetRoomEngine();
const wall = THICKNESS_RENDERER_VALUE[payload.thicknessWall];
const floor = THICKNESS_RENDERER_VALUE[payload.thicknessFloor];
if(engine && typeof engine.updateRoomInstancePlaneThickness === 'function' && wall !== undefined && floor !== undefined)
{
engine.updateRoomInstancePlaneThickness(roomId, wall, floor);
}
}
return true;
};
export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloorplanLiveSyncApi =>
{
const { enabled, state } = opts;
const session = useActiveRoomSessionSnapshot();
const roomId = session?.roomId ?? -1;
const baselineRef = useRef<LivePreviewPayload | null>(null);
const lastAppliedRef = useRef<LivePreviewPayload | null>(null);
// Destructure first so the memo deps stay precise without
// triggering exhaustive-deps on `state` as a whole.
const { tiles, door, thickness, wallHeight } = state;
const currentPayload = useMemo<LivePreviewPayload>(() => ({
tilemap: serializeTilemap(tiles),
doorX: door.x,
doorY: door.y,
doorDir: door.dir,
thicknessWall: thickness.wall,
thicknessFloor: thickness.floor,
wallHeight
}), [ tiles, door, thickness, wallHeight ]);
const setBaseline = useCallback((payload: LivePreviewPayload) =>
{
baselineRef.current = payload;
lastAppliedRef.current = payload;
}, []);
const revert = useCallback(() =>
{
const baseline = baselineRef.current;
if(!baseline) return;
if(applyToRenderer(baseline, roomId)) lastAppliedRef.current = baseline;
}, [ roomId ]);
// Apply the current payload to the renderer whenever it
// diverges from what's already in the room. Synchronous + no
// debounce — the renderer pipeline is fast enough that every
// brush stroke can land a paint.
useEffect(() =>
{
if(!enabled) return;
const previous = lastAppliedRef.current;
if(previous && livePreviewPayloadsEqual(currentPayload, previous)) return;
if(!previous && !baselineRef.current) return;
if(applyToRenderer(currentPayload, roomId)) lastAppliedRef.current = currentPayload;
}, [ enabled, currentPayload, roomId ]);
return { setBaseline, revert };
};