mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
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:
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user