mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(floorplan-editor): React rewrite + live in-room preview + UX polish
Complete modernization of the floor-plan editor. Three layered
changes shipped together since they share state shapes and the
test infrastructure stubs.
1) React rewrite (state + hooks + views + tests)
Drops the FloorplanEditorContext singleton + legacy view
components and replaces them with a pure-React reducer
architecture:
- state/ — typed FloorplanState + FloorplanAction union,
pure reducer covering PAINT_TILE / ERASE_TILE /
ADJUST_HEIGHT / SET_DOOR / SET_DOOR_DIR / SET_THICKNESS /
SET_WALL_HEIGHT / BRUSH_SET / SELECT_RECT / SELECT_ALL /
CLEAR_SELECTION / SQUARE_SELECT_TOGGLE / IMPORT_STRING /
APPLY_REMOTE_DIFF / APPLY_REMOTE_SNAPSHOT. Source-tagged
('local' | 'remote') so the editor can distinguish user
edits from server pushes. Co-located encoding helpers
(parseTilemap / serializeTilemap) and area-counter
selectors.
- hooks/ — useFloorplanReducer (wraps useReducer with a
history stack + loadFromServer + undo/redo), useTool
(pointer events -> dispatch), usePointerToTile (screen
-> tile projection that respects the viewBox origin so
pan/zoom stays accurate).
- views/ — FloorplanCanvasSVG, FloorplanHeightPicker,
FloorplanToolbar, FloorplanOptionsPanel,
FloorplanImportExport, FloorplanTile,
FloorplanPreviewSVG (alternative iso preview kept as a
fallback view, not wired into the main layout).
- Co-located Vitest suites for every module above (encoding,
reducer, selectors, hooks, views, integration). 100+ new
test cases.
2) Live in-room preview (NEW capability)
useFloorplanLiveSync drives client-side preview of the edit
directly into the active room — every tile / door / wall
height / thickness change is applied through
GetRoomMessageHandler().applyFloorModelLocally (new public
method on the renderer, see paired renderer PR) with
zero server traffic during editing. The wire
UpdateFloorPropertiesMessageComposer is only sent when the
user explicitly clicks Save. Thickness slider additionally
calls RoomEngine.updateRoomInstancePlaneThickness for
zero-latency wall/floor-depth feedback while dragging.
Toggle 'Live preview ON / OFF' in the bottom strip (default
ON) lets the user opt out if they want to keep changes
contained to the editor's own preview until Save.
Revert button re-applies the original snapshot locally so
the room snaps back to where it was when the editor opened.
3) UX polish
- Undo / Redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y) backed by a
100-step history stack inside useFloorplanReducer. Local
mutating actions push history; brush/selection UI bumps
and remote dispatches bypass it; loadFromServer wipes the
stack.
- Zoom 40-600 % with Ctrl+wheel, +/- buttons, % label.
Shift+drag or middle-mouse drag pans the canvas.
- Auto-fit on first paint: computes the screen-space
bounding box of the painted (non-blocked) tiles, picks the
zoom that just contains them with a 5 % margin, pans so
the room sits in the viewport centre. Default view is now
'room fills the canvas' instead of 'room is a dot at the
top-centre of a huge empty canvas'. Clicking the % label
re-runs the fit; crosshair button keeps zoom and recentres
the pan only.
- Door direction control: arrows + door icon triplet
(8-way rotate by single click on prev/next, full cycle
forward on the icon itself). Wall and floor thickness
collapse from two 4-button rows into two compact
segmented selectors (active state in emerald). Saves
significant horizontal space.
- Habbo floor pattern tile (~186 B PNG, vendored from
habbofurni.com/images/furni_floor.png) tiled as the
canvas background with image-rendering: pixelated so the
texture stays crisp at every zoom level. Replaces the
solid black background.
Test infrastructure
nitro-renderer.mock grows constructors / proxies / functions
for everything the new floor-editor tests transitively
import (floor composers + events, RoomEngineEvent,
ILinkEventTracker, convertNumbersForSaving /
convertSettingToNumber, GetRoomMessageHandler,
GetTicker, GetRenderer, NitroTicker, RoomPreviewer with a
sufficiently real .updatePreviewModel / dispose surface,
and a TextureUtils.createRenderTexture that returns an
object with a no-op .destroy). test-setup adds a no-op
ResizeObserver polyfill (jsdom doesn't ship one and the
optional FloorplanRoomPreview observes its container) and
a draggable-windows-container portal root for tests that
mount NitroCardView.
Files: 44 changed (mostly new). yarn typecheck 0 errors,
yarn test 341/341 green.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 186 B |
@@ -1,34 +0,0 @@
|
||||
import { createContext, Dispatch, FC, SetStateAction, useContext } from 'react';
|
||||
import { IFloorplanSettings } from '@nitrots/nitro-renderer';
|
||||
import { IVisualizationSettings } from '@nitrots/nitro-renderer';
|
||||
|
||||
interface IFloorplanEditorContext
|
||||
{
|
||||
originalFloorplanSettings: IFloorplanSettings;
|
||||
setOriginalFloorplanSettings: Dispatch<SetStateAction<IFloorplanSettings>>;
|
||||
visualizationSettings: IVisualizationSettings;
|
||||
setVisualizationSettings: Dispatch<SetStateAction<IVisualizationSettings>>;
|
||||
floorHeight: number;
|
||||
setFloorHeight: Dispatch<SetStateAction<number>>;
|
||||
floorAction: number;
|
||||
setFloorAction: Dispatch<SetStateAction<number>>;
|
||||
tilemapVersion: number;
|
||||
areaInfo: { total: number; walkable: number };
|
||||
}
|
||||
|
||||
const FloorplanEditorContext = createContext<IFloorplanEditorContext>({
|
||||
originalFloorplanSettings: null,
|
||||
setOriginalFloorplanSettings: null,
|
||||
visualizationSettings: null,
|
||||
setVisualizationSettings: null,
|
||||
floorHeight: 0,
|
||||
setFloorHeight: null,
|
||||
floorAction: 3,
|
||||
setFloorAction: null,
|
||||
tilemapVersion: 0,
|
||||
areaInfo: { total: 0, walkable: 0 }
|
||||
});
|
||||
|
||||
export const FloorplanEditorContextProvider: FC<{ value: IFloorplanEditorContext; children?: React.ReactNode }> = props => <FloorplanEditorContext { ...props } />;
|
||||
|
||||
export const useFloorplanEditorContext = () => useContext(FloorplanEditorContext);
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, render, cleanup, fireEvent } from '@testing-library/react';
|
||||
|
||||
// Capture handlers registered by useMessageEvent / useNitroEvent so we can fire fake events.
|
||||
const messageHandlers = new Map<unknown, (event: unknown) => void>();
|
||||
const nitroHandlers = new Map<unknown, (event: unknown) => void>();
|
||||
|
||||
vi.mock('../../hooks', async () =>
|
||||
{
|
||||
return {
|
||||
useMessageEvent: (eventClass: unknown, handler: (event: unknown) => void) =>
|
||||
{
|
||||
messageHandlers.set(eventClass, handler);
|
||||
},
|
||||
useNitroEvent: (eventType: unknown, handler: (event: unknown) => void) =>
|
||||
{
|
||||
nitroHandlers.set(eventType, handler);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Spy SendMessageComposer — use importOriginal to keep all other api exports intact
|
||||
// (DraggableWindow et al. rely on GetLocalStorage and others at mount time).
|
||||
const sendMessageComposer = vi.fn();
|
||||
vi.mock('../../api', async (importOriginal) =>
|
||||
{
|
||||
const actual = await importOriginal<typeof import('../../api')>();
|
||||
return {
|
||||
...actual,
|
||||
SendMessageComposer: (...args: unknown[]) => sendMessageComposer(...args),
|
||||
LocalizeText: (key: string) => key
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
FloorHeightMapEvent,
|
||||
RoomVisualizationSettingsEvent,
|
||||
RoomEntryTileMessageEvent,
|
||||
RoomOccupiedTilesMessageEvent,
|
||||
RoomEngineEvent,
|
||||
GetRoomEntryTileMessageComposer,
|
||||
GetOccupiedTilesMessageComposer,
|
||||
UpdateFloorPropertiesMessageComposer,
|
||||
AddLinkEventTracker,
|
||||
RemoveLinkEventTracker
|
||||
} from '@nitrots/nitro-renderer';
|
||||
import { FloorplanEditorView } from './FloorplanEditorView';
|
||||
|
||||
// The Button component in this codebase renders as a <div> (via Base), not <button>.
|
||||
// NitroCardView portals everything into #draggable-windows-container.
|
||||
// Find a clickable element by its exact trimmed text content in the portal.
|
||||
const findByExactText = (text: string): Element | undefined =>
|
||||
{
|
||||
const container = document.getElementById('draggable-windows-container') ?? document.body;
|
||||
return Array.from(container.querySelectorAll('div')).find(
|
||||
(el: Element) => el.textContent?.trim() === text
|
||||
);
|
||||
};
|
||||
|
||||
describe('FloorplanEditorView container', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
messageHandlers.clear();
|
||||
nitroHandlers.clear();
|
||||
sendMessageComposer.mockClear();
|
||||
(AddLinkEventTracker as ReturnType<typeof vi.fn>).mockClear();
|
||||
(RemoveLinkEventTracker as ReturnType<typeof vi.fn>).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const openEditor = () =>
|
||||
{
|
||||
render(<FloorplanEditorView />);
|
||||
// Trigger link tracker: 'floor-editor/show' to make editor visible
|
||||
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
act(() => tracker.linkReceived('floor-editor/show'));
|
||||
};
|
||||
|
||||
it('registers a link tracker on mount with floor-editor/ prefix', () =>
|
||||
{
|
||||
render(<FloorplanEditorView />);
|
||||
expect(AddLinkEventTracker).toHaveBeenCalledTimes(1);
|
||||
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(tracker.eventUrlPrefix).toBe('floor-editor/');
|
||||
});
|
||||
|
||||
it('dispatches GetRoomEntryTileMessageComposer when editor becomes visible', () =>
|
||||
{
|
||||
openEditor();
|
||||
const composers = sendMessageComposer.mock.calls.map((c: unknown[]) => c[0]);
|
||||
const entryComposer = composers.find((c: unknown) => c instanceof GetRoomEntryTileMessageComposer);
|
||||
expect(entryComposer).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dispatches GetOccupiedTilesMessageComposer when editor becomes visible', () =>
|
||||
{
|
||||
openEditor();
|
||||
const composers = sendMessageComposer.mock.calls.map((c: unknown[]) => c[0]);
|
||||
const occupiedComposer = composers.find((c: unknown) => c instanceof GetOccupiedTilesMessageComposer);
|
||||
expect(occupiedComposer).toBeTruthy();
|
||||
});
|
||||
|
||||
it('seeds door from RoomEntryTileMessageEvent', () =>
|
||||
{
|
||||
openEditor();
|
||||
const handler = messageHandlers.get(RoomEntryTileMessageEvent);
|
||||
expect(handler).toBeTruthy();
|
||||
act(() => handler!({ getParser: () => ({ x: 3, y: 4, direction: 6 }) }));
|
||||
// Seed tilemap + thickness so Save is callable
|
||||
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
|
||||
act(() => fhmHandler!({ getParser: () => ({ model: '00\rxq', wallHeight: 5 }) }));
|
||||
const rvsHandler = messageHandlers.get(RoomVisualizationSettingsEvent);
|
||||
act(() => rvsHandler!({ getParser: () => ({ thicknessWall: 1, thicknessFloor: 1 }) }));
|
||||
// With LocalizeText mocked to identity, the button text is literally the i18n key.
|
||||
// Button renders as <div> (not <button>) — use findByExactText.
|
||||
const saveBtn = findByExactText('floor.plan.editor.save');
|
||||
expect(saveBtn).toBeTruthy();
|
||||
sendMessageComposer.mockClear();
|
||||
fireEvent.click(saveBtn!);
|
||||
expect(sendMessageComposer).toHaveBeenCalledTimes(1);
|
||||
const composer = sendMessageComposer.mock.calls[0][0];
|
||||
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
|
||||
expect(composer.doorX).toBe(3);
|
||||
expect(composer.doorY).toBe(4);
|
||||
expect(composer.dir).toBe(6);
|
||||
});
|
||||
|
||||
it('Save composer carries wallHeight - 1 from the reducer state', () =>
|
||||
{
|
||||
openEditor();
|
||||
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
|
||||
// parser.wallHeight = 4 → state.wallHeight = 4 + 1 = 5 → Save sends 5 - 1 = 4
|
||||
act(() => fhmHandler!({ getParser: () => ({ model: '0', wallHeight: 4 }) }));
|
||||
const saveBtn = findByExactText('floor.plan.editor.save');
|
||||
expect(saveBtn).toBeTruthy();
|
||||
sendMessageComposer.mockClear();
|
||||
fireEvent.click(saveBtn!);
|
||||
const composer = sendMessageComposer.mock.calls[0][0];
|
||||
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
|
||||
expect(composer.wallHeight).toBe(4);
|
||||
});
|
||||
|
||||
it('Save composer thickness goes through convertNumbersForSaving', () =>
|
||||
{
|
||||
openEditor();
|
||||
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
|
||||
act(() => fhmHandler!({ getParser: () => ({ model: '0', wallHeight: 0 }) }));
|
||||
const rvsHandler = messageHandlers.get(RoomVisualizationSettingsEvent);
|
||||
// server sends 2 for both; convertSettingToNumber(2) = 3; reducer stores thickness=3
|
||||
// Save applies convertNumbersForSaving(3) = 1
|
||||
act(() => rvsHandler!({ getParser: () => ({ thicknessWall: 2, thicknessFloor: 2 }) }));
|
||||
const saveBtn = findByExactText('floor.plan.editor.save');
|
||||
expect(saveBtn).toBeTruthy();
|
||||
sendMessageComposer.mockClear();
|
||||
fireEvent.click(saveBtn!);
|
||||
const composer = sendMessageComposer.mock.calls[0][0];
|
||||
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
|
||||
expect(composer.thicknessWall).toBe(1);
|
||||
expect(composer.thicknessFloor).toBe(1);
|
||||
});
|
||||
|
||||
it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () =>
|
||||
{
|
||||
openEditor();
|
||||
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
|
||||
// 2x2 grid: '00\r00' → rows 0 and 1, each with 2 walkable tiles
|
||||
act(() => fhmHandler!({ getParser: () => ({ model: '00\r00', wallHeight: 0 }) }));
|
||||
const occHandler = messageHandlers.get(RoomOccupiedTilesMessageEvent);
|
||||
expect(occHandler).toBeTruthy();
|
||||
// Mark col 1 of row 0 as occupied; blockedTilesMap[row][col]
|
||||
const blockedTilesMap = [[false, true], [false, false]];
|
||||
act(() => occHandler!({ getParser: () => ({ blockedTilesMap }) }));
|
||||
const saveBtn = findByExactText('floor.plan.editor.save');
|
||||
expect(saveBtn).toBeTruthy();
|
||||
sendMessageComposer.mockClear();
|
||||
fireEvent.click(saveBtn!);
|
||||
const composer = sendMessageComposer.mock.calls[0][0];
|
||||
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
|
||||
// Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x'
|
||||
expect(composer.tilemap.split(/\r/)[0]).toBe('0x');
|
||||
});
|
||||
|
||||
it('RoomEngineEvent.DISPOSED hides the editor', () =>
|
||||
{
|
||||
render(<FloorplanEditorView />);
|
||||
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
act(() => tracker.linkReceived('floor-editor/show'));
|
||||
// Editor should be visible — NitroCardHeaderView renders the title
|
||||
expect(document.body.textContent).toContain('floor.plan.editor.title');
|
||||
const disposeHandler = nitroHandlers.get(RoomEngineEvent.DISPOSED);
|
||||
expect(disposeHandler).toBeTruthy();
|
||||
act(() => disposeHandler!({}));
|
||||
expect(document.body.textContent).not.toContain('floor.plan.editor.title');
|
||||
});
|
||||
|
||||
it('cleans up the link tracker on unmount', () =>
|
||||
{
|
||||
const { unmount } = render(<FloorplanEditorView />);
|
||||
expect(RemoveLinkEventTracker).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(RemoveLinkEventTracker).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,284 +1,299 @@
|
||||
import { AddLinkEventTracker, FloorHeightMapEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { FaCaretLeft, FaCaretRight } from 'react-icons/fa';
|
||||
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaBolt, FaCaretLeft, FaCaretRight } from 'react-icons/fa';
|
||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { Button, ButtonGroup, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { Button, ButtonGroup, 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, 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';
|
||||
import { useFloorplanLiveSync } from '../../hooks/rooms/widgets/useFloorplanLiveSync';
|
||||
import { MAX_WALL_HEIGHT, MIN_WALL_HEIGHT } from './state/constants';
|
||||
import { EntryDir, ThicknessLevel } from './state/types';
|
||||
import { areaCount } from './state/selectors';
|
||||
import { serializeTilemap } from './state/encoding';
|
||||
import { useFloorplanReducer } from './hooks/useFloorplanReducer';
|
||||
import { FloorplanCanvasSVG } from './views/FloorplanCanvasSVG';
|
||||
import { FloorplanHeightPicker } from './views/FloorplanHeightPicker';
|
||||
import { FloorplanToolbar } from './views/FloorplanToolbar';
|
||||
import { FloorplanOptionsPanel } from './views/FloorplanOptionsPanel';
|
||||
import { FloorplanImportExport } from './views/FloorplanImportExport';
|
||||
|
||||
const MIN_WALL_HEIGHT = 0;
|
||||
const MAX_WALL_HEIGHT = 16;
|
||||
const clampThickness = (v: number): ThicknessLevel =>
|
||||
{
|
||||
if(v <= 0) return 0;
|
||||
if(v >= 3) return 3;
|
||||
return (v | 0) as ThicknessLevel;
|
||||
};
|
||||
|
||||
export const FloorplanEditorView: FC<{}> = props =>
|
||||
export const FloorplanEditorView: FC = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ importExportVisible, setImportExportVisible ] = useState(false);
|
||||
const [ originalFloorplanSettings, setOriginalFloorplanSettings ] = useState<IFloorplanSettings>({
|
||||
tilemap: '',
|
||||
reservedTiles: [],
|
||||
entryPoint: [ 0, 0 ],
|
||||
entryPointDir: 2,
|
||||
wallHeight: -1,
|
||||
thicknessWall: 1,
|
||||
thicknessFloor: 1
|
||||
});
|
||||
const [ visualizationSettings, setVisualizationSettings ] = useState<IVisualizationSettings>({
|
||||
entryPointDir: 2,
|
||||
wallHeight: -1,
|
||||
thicknessWall: 1,
|
||||
thicknessFloor: 1
|
||||
});
|
||||
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 [ liveSync, setLiveSync ] = useState(true);
|
||||
const { state, dispatch, loadFromServer, undo, redo, canUndo, canRedo } = useFloorplanReducer();
|
||||
const originalRef = useRef<{
|
||||
tilemap: string;
|
||||
entryPoint: [number, number];
|
||||
entryPointDir: number;
|
||||
thicknessWall: ThicknessLevel;
|
||||
thicknessFloor: ThicknessLevel;
|
||||
wallHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
const calculateArea = useCallback(() =>
|
||||
{
|
||||
const tilemap = FloorplanEditor.instance.tilemap;
|
||||
const area = useMemo(() => areaCount(state.tiles), [ state.tiles ]);
|
||||
|
||||
if(!tilemap || tilemap.length === 0)
|
||||
{
|
||||
setAreaInfo({ total: 0, walkable: 0 });
|
||||
// Live in-room preview: while the editor is open every tile /
|
||||
// door / thickness / wallHeight change is applied immediately
|
||||
// to the 3D room behind the editor card, CLIENT-SIDE ONLY (no
|
||||
// server packet). The wire UpdateFloorPropertiesMessageComposer
|
||||
// is only sent when the user clicks Save. `setBaseline` is
|
||||
// called by the message handlers below so the hook knows what
|
||||
// state to roll back to if the user closes without saving.
|
||||
const { setBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state });
|
||||
|
||||
return;
|
||||
}
|
||||
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, () => setIsVisible(false));
|
||||
|
||||
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;
|
||||
SendMessageComposer(new GetRoomEntryTileMessageComposer());
|
||||
SendMessageComposer(new GetOccupiedTilesMessageComposer());
|
||||
}, [ isVisible ]);
|
||||
|
||||
FloorplanEditor.instance.onTilemapChange = () =>
|
||||
{
|
||||
setTilemapVersion(prev => prev + 1);
|
||||
calculateArea();
|
||||
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
originalRef.current = {
|
||||
tilemap: originalRef.current?.tilemap ?? '',
|
||||
entryPoint: [ parser.x, parser.y ],
|
||||
entryPointDir: parser.direction,
|
||||
thicknessWall: originalRef.current?.thicknessWall ?? 1,
|
||||
thicknessFloor: originalRef.current?.thicknessFloor ?? 1,
|
||||
wallHeight: originalRef.current?.wallHeight ?? -1
|
||||
};
|
||||
dispatch({ type: 'SET_DOOR', x: parser.x, y: parser.y, source: 'remote' });
|
||||
dispatch({ type: 'SET_DOOR_DIR', dir: ((parser.direction | 0) & 7) as EntryDir, source: 'remote' });
|
||||
});
|
||||
|
||||
return () =>
|
||||
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const blockedTilesMap = parser.blockedTilesMap;
|
||||
const diffTiles: Array<{ row: number; col: number; h: number; blocked: boolean }> = [];
|
||||
for(let row = 0; row < blockedTilesMap.length; row++)
|
||||
{
|
||||
FloorplanEditor.instance.onTilemapChange = null;
|
||||
};
|
||||
}, [ isVisible, calculateArea ]);
|
||||
|
||||
const saveFloorChanges = () =>
|
||||
{
|
||||
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
|
||||
FloorplanEditor.instance.getCurrentTilemapString(),
|
||||
FloorplanEditor.instance.doorLocation.x,
|
||||
FloorplanEditor.instance.doorLocation.y,
|
||||
visualizationSettings.entryPointDir,
|
||||
convertNumbersForSaving(visualizationSettings.thicknessWall),
|
||||
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));
|
||||
const rowArr = blockedTilesMap[row];
|
||||
if(!rowArr) continue;
|
||||
for(let col = 0; col < rowArr.length; col++)
|
||||
{
|
||||
if(rowArr[col]) diffTiles.push({ row, col, h: 0, blocked: true });
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'APPLY_REMOTE_DIFF', diff: { tiles: diffTiles }, seq: 0, editorUserId: 0 });
|
||||
});
|
||||
|
||||
useMessageEvent<FloorHeightMapEvent>(FloorHeightMapEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setOriginalFloorplanSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.tilemap = parser.model;
|
||||
newValue.wallHeight = (parser.wallHeight + 1);
|
||||
|
||||
return newValue;
|
||||
originalRef.current = {
|
||||
tilemap: parser.model,
|
||||
entryPoint: originalRef.current?.entryPoint ?? [ 0, 0 ],
|
||||
entryPointDir: originalRef.current?.entryPointDir ?? 2,
|
||||
thicknessWall: originalRef.current?.thicknessWall ?? 1,
|
||||
thicknessFloor: originalRef.current?.thicknessFloor ?? 1,
|
||||
wallHeight: parser.wallHeight + 1
|
||||
};
|
||||
loadFromServer({
|
||||
tilemap: parser.model,
|
||||
entryPoint: originalRef.current.entryPoint,
|
||||
entryPointDir: originalRef.current.entryPointDir,
|
||||
thicknessWall: originalRef.current.thicknessWall,
|
||||
thicknessFloor: originalRef.current.thicknessFloor,
|
||||
wallHeight: parser.wallHeight + 1
|
||||
});
|
||||
|
||||
setVisualizationSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.wallHeight = (parser.wallHeight + 1);
|
||||
|
||||
return newValue;
|
||||
// Anchor the live-sync baseline at the server's authoritative
|
||||
// snapshot so the first re-render after this load doesn't
|
||||
// bounce the same model back as an "edit".
|
||||
setBaseline({
|
||||
tilemap: parser.model,
|
||||
doorX: originalRef.current.entryPoint[0],
|
||||
doorY: originalRef.current.entryPoint[1],
|
||||
doorDir: originalRef.current.entryPointDir,
|
||||
thicknessWall: originalRef.current.thicknessWall,
|
||||
thicknessFloor: originalRef.current.thicknessFloor,
|
||||
wallHeight: parser.wallHeight + 1
|
||||
});
|
||||
});
|
||||
|
||||
useMessageEvent<RoomVisualizationSettingsEvent>(RoomVisualizationSettingsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setOriginalFloorplanSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.thicknessFloor = convertSettingToNumber(parser.thicknessFloor);
|
||||
newValue.thicknessWall = convertSettingToNumber(parser.thicknessWall);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
setVisualizationSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.thicknessFloor = convertSettingToNumber(parser.thicknessFloor);
|
||||
newValue.thicknessWall = convertSettingToNumber(parser.thicknessWall);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
const wall = clampThickness(convertSettingToNumber(parser.thicknessWall));
|
||||
const floor = clampThickness(convertSettingToNumber(parser.thicknessFloor));
|
||||
originalRef.current = {
|
||||
tilemap: originalRef.current?.tilemap ?? '',
|
||||
entryPoint: originalRef.current?.entryPoint ?? [ 0, 0 ],
|
||||
entryPointDir: originalRef.current?.entryPointDir ?? 2,
|
||||
thicknessWall: wall,
|
||||
thicknessFloor: floor,
|
||||
wallHeight: originalRef.current?.wallHeight ?? -1
|
||||
};
|
||||
dispatch({ type: 'SET_THICKNESS', wall, floor, source: 'remote' });
|
||||
});
|
||||
|
||||
// Keyboard shortcuts: Ctrl+Z = undo, Ctrl+Shift+Z / Ctrl+Y = redo.
|
||||
// Scoped to when the editor is visible; ignored when focus is in
|
||||
// a text-entry field (Import/Export modal textarea, wall height
|
||||
// input) so we don't fight the OS-native undo.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
const handler = (e: KeyboardEvent) =>
|
||||
{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
if(tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if(key === 'z' && !e.shiftKey)
|
||||
{
|
||||
e.preventDefault();
|
||||
undo();
|
||||
}
|
||||
else if((key === 'z' && e.shiftKey) || key === 'y')
|
||||
{
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [ isVisible, undo, redo ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const linkTracker: ILinkEventTracker = {
|
||||
linkReceived: (url: string) =>
|
||||
{
|
||||
const parts = url.split('/');
|
||||
|
||||
if(parts.length < 2) return;
|
||||
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show':
|
||||
setIsVisible(true);
|
||||
return;
|
||||
case 'hide':
|
||||
setIsVisible(false);
|
||||
return;
|
||||
case 'toggle':
|
||||
setIsVisible(prevValue => !prevValue);
|
||||
return;
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'toggle': setIsVisible(v => !v); return;
|
||||
}
|
||||
},
|
||||
eventUrlPrefix: 'floor-editor/'
|
||||
};
|
||||
|
||||
AddLinkEventTracker(linkTracker);
|
||||
|
||||
return () => RemoveLinkEventTracker(linkTracker);
|
||||
}, []);
|
||||
|
||||
const onWallHeightChange = (value: number) =>
|
||||
{
|
||||
if(isNaN(value) || value <= 0) value = MIN_WALL_HEIGHT;
|
||||
if(value > MAX_WALL_HEIGHT) value = MAX_WALL_HEIGHT;
|
||||
dispatch({ type: 'SET_WALL_HEIGHT', value, source: 'local' });
|
||||
};
|
||||
|
||||
const saveFloorChanges = () =>
|
||||
{
|
||||
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
|
||||
serializeTilemap(state.tiles),
|
||||
state.door.x,
|
||||
state.door.y,
|
||||
state.door.dir,
|
||||
convertNumbersForSaving(state.thickness.wall),
|
||||
convertNumbersForSaving(state.thickness.floor),
|
||||
state.wallHeight - 1
|
||||
));
|
||||
};
|
||||
|
||||
const revertChanges = () =>
|
||||
{
|
||||
const o = originalRef.current;
|
||||
if(!o) return;
|
||||
loadFromServer(o);
|
||||
// Roll the live in-room preview back to the server-known
|
||||
// baseline. No-op if live sync is off (nothing was changed
|
||||
// in the room).
|
||||
if(liveSync) revertLivePreview();
|
||||
};
|
||||
|
||||
return (
|
||||
<FloorplanEditorContextProvider value={ {
|
||||
originalFloorplanSettings,
|
||||
setOriginalFloorplanSettings,
|
||||
visualizationSettings,
|
||||
setVisualizationSettings,
|
||||
floorHeight,
|
||||
setFloorHeight,
|
||||
floorAction,
|
||||
setFloorAction,
|
||||
tilemapVersion,
|
||||
areaInfo
|
||||
} }>
|
||||
{ isVisible &&
|
||||
<NitroCardView uniqueKey="floorpan-editor" className="w-[1100px] h-[600px]" theme="primary-slim">
|
||||
<>
|
||||
{ isVisible && (
|
||||
<NitroCardView uniqueKey="floorpan-editor" className="w-[820px] h-[620px]" theme="primary-slim">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.title') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCardContentView overflow="hidden" className="flex flex-col">
|
||||
<FloorplanOptionsView />
|
||||
<NitroCardContentView overflow="hidden" className="flex flex-col gap-2">
|
||||
<FloorplanToolbar
|
||||
state={ state }
|
||||
dispatch={ dispatch }
|
||||
canUndo={ canUndo }
|
||||
canRedo={ canRedo }
|
||||
onUndo={ undo }
|
||||
onRedo={ redo }
|
||||
/>
|
||||
<FloorplanOptionsPanel state={ state } dispatch={ dispatch } />
|
||||
<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>
|
||||
<FloorplanHeightPicker selectedH={ state.brush.h } onSelect={ h => dispatch({ type: 'BRUSH_SET', h }) } />
|
||||
<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />
|
||||
</Flex>
|
||||
<Flex gap={ 3 } alignItems="center" className="px-1">
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Text bold small className="text-zinc-700">{ LocalizeText('floor.editor.wall.height') }</Text>
|
||||
<FaCaretLeft className="cursor-pointer fa-icon text-zinc-600" onClick={ () => onWallHeightChange(state.wallHeight - 1) } />
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm w-[49px] text-center"
|
||||
value={ state.wallHeight }
|
||||
onChange={ e => onWallHeightChange(e.target.valueAsNumber) }
|
||||
/>
|
||||
<FaCaretRight className="cursor-pointer fa-icon text-zinc-600" onClick={ () => onWallHeightChange(state.wallHeight + 1) } />
|
||||
</Flex>
|
||||
<Text bold small className="text-zinc-700">
|
||||
Area: <span className="tabular-nums">{ area.total }</span> ({ area.walkable } caselle)
|
||||
</Text>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
gap={ 1 }
|
||||
className={ `ml-auto border rounded px-2 py-1 cursor-pointer select-none ${ liveSync ? 'bg-emerald-500/15 border-emerald-500 text-emerald-700' : 'border-zinc-400 text-zinc-600' }` }
|
||||
onClick={ () => setLiveSync(v => !v) }
|
||||
title="Anteprima locale nella stanza mentre disegni (non salva al server)"
|
||||
>
|
||||
<FaBolt className={ liveSync ? 'text-emerald-600' : 'text-zinc-500' } />
|
||||
<Text bold small>{ liveSync ? 'Live preview ON' : 'Live preview OFF' }</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex justifyContent="between">
|
||||
<Button variant="danger" onClick={ revertChanges }>{ LocalizeText('floor.plan.editor.reload') }</Button>
|
||||
<ButtonGroup>
|
||||
<Button onClick={ event => setImportExportVisible(true) }>{ LocalizeText('floor.plan.editor.import.export') }</Button>
|
||||
<Button onClick={ () => setImportExportVisible(true) }>{ LocalizeText('floor.plan.editor.import.export') }</Button>
|
||||
<Button onClick={ saveFloorChanges }>{ LocalizeText('floor.plan.editor.save') }</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView> }
|
||||
{ importExportVisible &&
|
||||
<FloorplanImportExportView onCloseClick={ () => setImportExportVisible(false) } /> }
|
||||
</FloorplanEditorContextProvider>
|
||||
</NitroCardView>
|
||||
) }
|
||||
{ importExportVisible && (
|
||||
<FloorplanImportExport
|
||||
state={ state }
|
||||
dispatch={ dispatch }
|
||||
onClose={ () => setImportExportVisible(false) }
|
||||
onSaveFromText={ raw =>
|
||||
{
|
||||
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
|
||||
raw,
|
||||
state.door.x,
|
||||
state.door.y,
|
||||
state.door.dir,
|
||||
convertNumbersForSaving(state.thickness.wall),
|
||||
convertNumbersForSaving(state.thickness.floor),
|
||||
state.wallHeight - 1
|
||||
));
|
||||
} }
|
||||
onRevertText={ () => originalRef.current?.tilemap ?? serializeTilemap(state.tiles) }
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useFloorplanReducer } from './useFloorplanReducer';
|
||||
|
||||
describe('useFloorplanReducer', () =>
|
||||
{
|
||||
it('starts with initialState', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useFloorplanReducer());
|
||||
expect(result.current.state.tiles).toEqual([]);
|
||||
expect(result.current.state.brush.action).toBe('SET');
|
||||
});
|
||||
|
||||
it('loadFromServer seeds tiles + door + wallHeight', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useFloorplanReducer());
|
||||
act(() =>
|
||||
{
|
||||
result.current.loadFromServer({
|
||||
tilemap: '00\rxq',
|
||||
entryPoint: [ 1, 0 ],
|
||||
entryPointDir: 4,
|
||||
thicknessWall: 1,
|
||||
thicknessFloor: 0,
|
||||
wallHeight: 5
|
||||
});
|
||||
});
|
||||
expect(result.current.state.tiles).toHaveLength(2);
|
||||
expect(result.current.state.door).toEqual({ x: 1, y: 0, dir: 4 });
|
||||
expect(result.current.state.thickness).toEqual({ wall: 1, floor: 0 });
|
||||
expect(result.current.state.wallHeight).toBe(5);
|
||||
});
|
||||
|
||||
it('dispatch updates state synchronously', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useFloorplanReducer());
|
||||
act(() =>
|
||||
{
|
||||
result.current.dispatch({ type: 'BRUSH_SET', action: 'DOOR' });
|
||||
});
|
||||
expect(result.current.state.brush.action).toBe('DOOR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Dispatch, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { FloorplanAction, FloorplanState, EntryDir, ThicknessLevel } from '../state/types';
|
||||
import { initialState, reducer } from '../state/reducer';
|
||||
|
||||
export type ServerFloorSettings = {
|
||||
tilemap: string;
|
||||
entryPoint: [number, number];
|
||||
entryPointDir: number;
|
||||
thicknessWall: ThicknessLevel;
|
||||
thicknessFloor: ThicknessLevel;
|
||||
wallHeight: number;
|
||||
};
|
||||
|
||||
type Api = {
|
||||
state: FloorplanState;
|
||||
dispatch: Dispatch<FloorplanAction>;
|
||||
loadFromServer: (s: ServerFloorSettings) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
};
|
||||
|
||||
// Actions that DON'T change the room model — they only affect the
|
||||
// editor's UI state (brush selection, drag-select rectangle, …) and
|
||||
// should NOT push a new history snapshot. Brushing a tile, moving a
|
||||
// door, changing thickness, etc. all DO push history.
|
||||
const isNonHistoryAction = (action: FloorplanAction): boolean =>
|
||||
{
|
||||
switch(action.type)
|
||||
{
|
||||
case 'BRUSH_SET':
|
||||
case 'SELECT_ALL':
|
||||
case 'CLEAR_SELECTION':
|
||||
case 'SELECT_RECT':
|
||||
case 'SQUARE_SELECT_TOGGLE':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Remote-driven actions also bypass history — they represent the
|
||||
// "true" server state, not a user edit. Treating a server push as
|
||||
// a history step would let the user "undo" a server snapshot, which
|
||||
// makes no sense.
|
||||
const isRemoteAction = (action: FloorplanAction): boolean =>
|
||||
{
|
||||
if(action.type === 'APPLY_REMOTE_DIFF' || action.type === 'APPLY_REMOTE_SNAPSHOT') return true;
|
||||
return 'source' in action && action.source === 'remote';
|
||||
};
|
||||
|
||||
const HISTORY_LIMIT = 100;
|
||||
|
||||
export const useFloorplanReducer = (): Api =>
|
||||
{
|
||||
const [ state, dispatch ] = useReducer(reducer, initialState);
|
||||
|
||||
// Past / future stacks — paired with `state` to form a linear
|
||||
// timeline (`past` ++ [state] ++ `future`). Refs because the
|
||||
// wrappedDispatch closure needs the latest value but we don't
|
||||
// want every push to trigger a re-render. canUndo / canRedo are
|
||||
// separately tracked as React state so the UI buttons disable
|
||||
// correctly.
|
||||
const pastRef = useRef<FloorplanState[]>([]);
|
||||
const futureRef = useRef<FloorplanState[]>([]);
|
||||
const [ canUndo, setCanUndo ] = useState(false);
|
||||
const [ canRedo, setCanRedo ] = useState(false);
|
||||
const stateRef = useRef<FloorplanState>(state);
|
||||
|
||||
// Keep stateRef in sync with the latest committed render so the
|
||||
// history pushers (which run inside callbacks, not during
|
||||
// render) always see the up-to-date state. Writing the ref
|
||||
// inside an effect — not directly in the render body — is what
|
||||
// React's `refs-during-render` rule enforces.
|
||||
useEffect(() =>
|
||||
{
|
||||
stateRef.current = state;
|
||||
}, [ state ]);
|
||||
|
||||
const refreshCanFlags = useCallback(() =>
|
||||
{
|
||||
setCanUndo(pastRef.current.length > 0);
|
||||
setCanRedo(futureRef.current.length > 0);
|
||||
}, []);
|
||||
|
||||
const wrappedDispatch = useCallback<Dispatch<FloorplanAction>>((action) =>
|
||||
{
|
||||
if(isNonHistoryAction(action) || isRemoteAction(action))
|
||||
{
|
||||
dispatch(action);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local edit: push current state onto past, drop future
|
||||
// (any redo branch is invalidated by a new edit).
|
||||
pastRef.current.push(stateRef.current);
|
||||
|
||||
if(pastRef.current.length > HISTORY_LIMIT) pastRef.current.shift();
|
||||
|
||||
futureRef.current = [];
|
||||
|
||||
dispatch(action);
|
||||
refreshCanFlags();
|
||||
}, [ refreshCanFlags ]);
|
||||
|
||||
const loadFromServer = useCallback((s: ServerFloorSettings) =>
|
||||
{
|
||||
// Server load wipes history — the document is fresh.
|
||||
pastRef.current = [];
|
||||
futureRef.current = [];
|
||||
dispatch({
|
||||
type: 'IMPORT_STRING',
|
||||
raw: s.tilemap,
|
||||
door: { x: s.entryPoint[0], y: s.entryPoint[1], dir: ((s.entryPointDir | 0) & 7) as EntryDir },
|
||||
thickness: { wall: s.thicknessWall, floor: s.thicknessFloor },
|
||||
wallHeight: s.wallHeight,
|
||||
source: 'remote'
|
||||
});
|
||||
refreshCanFlags();
|
||||
}, [ refreshCanFlags ]);
|
||||
|
||||
const undo = useCallback(() =>
|
||||
{
|
||||
const previous = pastRef.current.pop();
|
||||
|
||||
if(!previous) return;
|
||||
|
||||
futureRef.current.push(stateRef.current);
|
||||
dispatch({ type: 'APPLY_REMOTE_SNAPSHOT',
|
||||
raw: serializeTilesForSnapshot(previous.tiles),
|
||||
door: previous.door,
|
||||
thickness: previous.thickness,
|
||||
wallHeight: previous.wallHeight,
|
||||
seq: previous.seq });
|
||||
// The APPLY_REMOTE_SNAPSHOT action re-parses the tilemap;
|
||||
// but we also want to restore brush/selection state. Wrap
|
||||
// the dispatch in an effect-like immediate sync by writing
|
||||
// through stateRef AFTER React commits — handled by the
|
||||
// next render setting stateRef. The selection/brush carried
|
||||
// by `previous` is recovered on the next mutating dispatch
|
||||
// since the reducer's APPLY_REMOTE_SNAPSHOT path resets
|
||||
// selection (acceptable: undoing a paint clears the
|
||||
// selection rectangle, which matches user intuition).
|
||||
refreshCanFlags();
|
||||
}, [ refreshCanFlags ]);
|
||||
|
||||
const redo = useCallback(() =>
|
||||
{
|
||||
const next = futureRef.current.pop();
|
||||
|
||||
if(!next) return;
|
||||
|
||||
pastRef.current.push(stateRef.current);
|
||||
dispatch({ type: 'APPLY_REMOTE_SNAPSHOT',
|
||||
raw: serializeTilesForSnapshot(next.tiles),
|
||||
door: next.door,
|
||||
thickness: next.thickness,
|
||||
wallHeight: next.wallHeight,
|
||||
seq: next.seq });
|
||||
refreshCanFlags();
|
||||
}, [ refreshCanFlags ]);
|
||||
|
||||
return useMemo(() => ({
|
||||
state, dispatch: wrappedDispatch, loadFromServer, undo, redo, canUndo, canRedo
|
||||
}), [ state, wrappedDispatch, loadFromServer, undo, redo, canUndo, canRedo ]);
|
||||
};
|
||||
|
||||
// Local serializer mirror — the reducer's APPLY_REMOTE_SNAPSHOT
|
||||
// path takes a raw tilemap string, but our history entries are
|
||||
// the live Tile[][] arrays. Re-emit `\r`-joined rows in the same
|
||||
// shape the encoding module uses for SAVES (we keep this here to
|
||||
// avoid a circular import: state/reducer already imports
|
||||
// state/encoding).
|
||||
const serializeTilesForSnapshot = (tiles: { h: number; blocked: boolean }[][]): string =>
|
||||
{
|
||||
if(!tiles || tiles.length === 0) return '';
|
||||
const scheme = 'x0123456789abcdefghijklmnopq';
|
||||
return tiles.map(row => row.map(tile =>
|
||||
{
|
||||
if(tile.blocked) return 'x';
|
||||
const h = Number.isFinite(tile.h) ? Math.max(0, Math.min(scheme.length - 2, tile.h)) : 0;
|
||||
return scheme.charAt(h + 1);
|
||||
}).join('')).join('\r');
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { usePointerToTile, screenToTile, tileToScreen } from './usePointerToTile';
|
||||
|
||||
describe('tileToScreen / screenToTile round-trip', () =>
|
||||
{
|
||||
it('origin tile (0,0) projects to (1024, 0) and back', () =>
|
||||
{
|
||||
const [ sx, sy ] = tileToScreen(0, 0);
|
||||
expect(sx).toBe(1024);
|
||||
expect(sy).toBe(0);
|
||||
expect(screenToTile(sx, sy)).toEqual([ 0, 0 ]);
|
||||
});
|
||||
|
||||
it('tile (3, 5) round-trips', () =>
|
||||
{
|
||||
const [ sx, sy ] = tileToScreen(3, 5);
|
||||
const [ r, c ] = screenToTile(sx, sy);
|
||||
expect(r).toBeCloseTo(3, 5);
|
||||
expect(c).toBeCloseTo(5, 5);
|
||||
});
|
||||
|
||||
it('rounds to the containing diamond for jittered points', () =>
|
||||
{
|
||||
// The diamond for tile (R, C) is centered at tileToScreen(R, C).
|
||||
// Small jitter inside the diamond should still resolve to the same tile under round-to-nearest.
|
||||
const [ sx, sy ] = tileToScreen(7, 2);
|
||||
const [ r, c ] = screenToTile(sx + 2, sy + 1);
|
||||
expect(Math.round(r)).toBe(7);
|
||||
expect(Math.round(c)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePointerToTile', () =>
|
||||
{
|
||||
it('returns null when no SVG ref is attached', () =>
|
||||
{
|
||||
const ref = { current: null } as React.RefObject<SVGSVGElement | null>;
|
||||
const { result } = renderHook(() => usePointerToTile(ref, { width: 2048, height: 1024 }));
|
||||
expect(result.current.fromClient(100, 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { RefObject, useCallback, useMemo } from 'react';
|
||||
import { TILE_SIZE } from '../state/constants';
|
||||
|
||||
const X_OFFSET = 1024;
|
||||
|
||||
export const tileToScreen = (row: number, col: number): [number, number] =>
|
||||
{
|
||||
const x = (col * TILE_SIZE / 2) - (row * TILE_SIZE / 2) + X_OFFSET;
|
||||
const y = (col * TILE_SIZE / 4) + (row * TILE_SIZE / 4);
|
||||
return [ x, y ];
|
||||
};
|
||||
|
||||
export const screenToTile = (x: number, y: number): [number, number] =>
|
||||
{
|
||||
const tx = x - X_OFFSET;
|
||||
const col = ((tx / (TILE_SIZE / 2)) + (y / (TILE_SIZE / 4))) / 2;
|
||||
const row = ((y / (TILE_SIZE / 4)) - (tx / (TILE_SIZE / 2))) / 2;
|
||||
return [ row, col ];
|
||||
};
|
||||
|
||||
type ViewBox = { width: number; height: number; x?: number; y?: number };
|
||||
|
||||
export type PointerProjection = {
|
||||
fromClient: (clientX: number, clientY: number) => { row: number; col: number } | null;
|
||||
};
|
||||
|
||||
export const usePointerToTile = (
|
||||
svgRef: RefObject<SVGSVGElement | null>,
|
||||
viewBox: ViewBox
|
||||
): PointerProjection =>
|
||||
{
|
||||
const { width, height, x: viewX = 0, y: viewY = 0 } = viewBox;
|
||||
|
||||
const fromClient = useCallback((clientX: number, clientY: number) =>
|
||||
{
|
||||
const svg = svgRef.current;
|
||||
if(!svg) return null;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
if(rect.width === 0 || rect.height === 0) return null;
|
||||
// Map screen-space pointer onto the viewBox interior, then
|
||||
// shift by the viewBox origin — when zoomed in the viewBox
|
||||
// starts at (viewX, viewY) instead of (0, 0), so a pointer
|
||||
// at the left edge of the SVG corresponds to viewX in
|
||||
// local SVG units, not 0.
|
||||
const localX = viewX + ((clientX - rect.left) / rect.width) * width;
|
||||
const localY = viewY + ((clientY - rect.top) / rect.height) * height;
|
||||
const [ row, col ] = screenToTile(localX, localY);
|
||||
return { row: Math.round(row), col: Math.round(col) };
|
||||
}, [ svgRef, width, height, viewX, viewY ]);
|
||||
|
||||
return useMemo(() => ({ fromClient }), [ fromClient ]);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useTool } from './useTool';
|
||||
import { FloorplanState, FloorplanAction } from '../state/types';
|
||||
import { initialState } from '../state/reducer';
|
||||
|
||||
const withBrush = (action: FloorplanState['brush']['action'], h = 0): FloorplanState =>
|
||||
({ ...initialState, brush: { h, action } });
|
||||
|
||||
const mockProjection = (tile: { row: number; col: number } | null) => ({
|
||||
fromClient: () => tile
|
||||
});
|
||||
|
||||
describe('useTool', () =>
|
||||
{
|
||||
it('SET dispatches PAINT_TILE on pointer down at hit tile', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useTool(withBrush('SET', 3), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 1, col: 2 })));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'PAINT_TILE', row: 1, col: 2, h: 3, source: 'local' });
|
||||
});
|
||||
|
||||
it('UNSET dispatches ERASE_TILE', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useTool(withBrush('UNSET'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 0, col: 0 })));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'ERASE_TILE', row: 0, col: 0, source: 'local' });
|
||||
});
|
||||
|
||||
it('UP dispatches ADJUST_HEIGHT delta=+1', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useTool(withBrush('UP'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 5, col: 6 })));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'ADJUST_HEIGHT', row: 5, col: 6, delta: 1, source: 'local' });
|
||||
});
|
||||
|
||||
it('DOWN dispatches ADJUST_HEIGHT delta=-1', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useTool(withBrush('DOWN'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 1, col: 1 })));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'ADJUST_HEIGHT', row: 1, col: 1, delta: -1, source: 'local' });
|
||||
});
|
||||
|
||||
it('DOOR dispatches SET_DOOR with row→y, col→x', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useTool(withBrush('DOOR'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 4, col: 7 })));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_DOOR', x: 7, y: 4, source: 'local' });
|
||||
});
|
||||
|
||||
it('does nothing when projection returns null', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useTool(withBrush('SET'), dispatch as React.Dispatch<FloorplanAction>, mockProjection(null)));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PAINT continues on pointer move when dragging', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
let projTile: { row: number; col: number } = { row: 0, col: 0 };
|
||||
const projection = { fromClient: () => projTile };
|
||||
const { result } = renderHook(() => useTool(withBrush('SET', 0), dispatch as React.Dispatch<FloorplanAction>, projection));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
dispatch.mockClear();
|
||||
projTile = { row: 0, col: 1 };
|
||||
act(() => result.current.onPointerMove({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'PAINT_TILE', row: 0, col: 1, h: 0, source: 'local' });
|
||||
});
|
||||
|
||||
it('PAINT does not re-dispatch on move within same tile', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const projection = { fromClient: () => ({ row: 0, col: 0 }) };
|
||||
const { result } = renderHook(() => useTool(withBrush('SET'), dispatch as React.Dispatch<FloorplanAction>, projection));
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
dispatch.mockClear();
|
||||
act(() => result.current.onPointerMove({ clientX: 1, clientY: 1, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Dispatch, PointerEvent, useCallback, useRef } from 'react';
|
||||
import { FloorplanAction, FloorplanState } from '../state/types';
|
||||
import { PointerProjection } from './usePointerToTile';
|
||||
|
||||
type Handlers = {
|
||||
onPointerDown: (e: PointerEvent<SVGSVGElement>) => void;
|
||||
onPointerMove: (e: PointerEvent<SVGSVGElement>) => void;
|
||||
onPointerUp: (e: PointerEvent<SVGSVGElement>) => void;
|
||||
};
|
||||
|
||||
const tileKey = (row: number, col: number) => `${ row },${ col }` as const;
|
||||
|
||||
const dispatchForBrush = (
|
||||
action: FloorplanState['brush']['action'],
|
||||
h: number,
|
||||
row: number,
|
||||
col: number,
|
||||
dispatch: Dispatch<FloorplanAction>
|
||||
): void =>
|
||||
{
|
||||
switch(action)
|
||||
{
|
||||
case 'SET': dispatch({ type: 'PAINT_TILE', row, col, h, source: 'local' }); return;
|
||||
case 'UNSET': dispatch({ type: 'ERASE_TILE', row, col, source: 'local' }); return;
|
||||
case 'UP': dispatch({ type: 'ADJUST_HEIGHT', row, col, delta: 1, source: 'local' }); return;
|
||||
case 'DOWN': dispatch({ type: 'ADJUST_HEIGHT', row, col, delta: -1, source: 'local' }); return;
|
||||
case 'DOOR': dispatch({ type: 'SET_DOOR', x: col, y: row, source: 'local' }); return;
|
||||
}
|
||||
};
|
||||
|
||||
export const useTool = (
|
||||
state: FloorplanState,
|
||||
dispatch: Dispatch<FloorplanAction>,
|
||||
projection: PointerProjection
|
||||
): Handlers =>
|
||||
{
|
||||
const isDownRef = useRef(false);
|
||||
const lastTileRef = useRef<string | null>(null);
|
||||
|
||||
const apply = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
const hit = projection.fromClient(e.clientX, e.clientY);
|
||||
if(!hit) return;
|
||||
const key = tileKey(hit.row, hit.col);
|
||||
if(key === lastTileRef.current) return;
|
||||
lastTileRef.current = key;
|
||||
dispatchForBrush(state.brush.action, state.brush.h, hit.row, hit.col, dispatch);
|
||||
}, [ projection, state.brush.action, state.brush.h, dispatch ]);
|
||||
|
||||
const onPointerDown = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
isDownRef.current = true;
|
||||
lastTileRef.current = null;
|
||||
try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch {}
|
||||
apply(e);
|
||||
}, [ apply ]);
|
||||
|
||||
const onPointerMove = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
if(!isDownRef.current) return;
|
||||
if(state.brush.action === 'DOOR') return; // door is a single-click placement
|
||||
apply(e);
|
||||
}, [ apply, state.brush.action ]);
|
||||
|
||||
const onPointerUp = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
isDownRef.current = false;
|
||||
lastTileRef.current = null;
|
||||
try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch {}
|
||||
}, []);
|
||||
|
||||
return { onPointerDown, onPointerMove, onPointerUp };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
FloorAction,
|
||||
HEIGHT_SCHEME,
|
||||
COLORMAP,
|
||||
TILE_SIZE,
|
||||
MAX_NUM_TILE_PER_AXIS
|
||||
} from '@nitrots/nitro-renderer';
|
||||
|
||||
export const MIN_WALL_HEIGHT = 0;
|
||||
export const MAX_WALL_HEIGHT = 16;
|
||||
export const HEIGHT_BRUSH_MIN = 0;
|
||||
export const HEIGHT_BRUSH_MAX = 26;
|
||||
|
||||
// Empty (uninitialized) door used as initial state until a server event arrives.
|
||||
export const EMPTY_DOOR = { x: 0, y: 0, dir: 2 as const };
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseTilemap,
|
||||
serializeTilemap,
|
||||
tileToChar,
|
||||
charToTile
|
||||
} from './encoding';
|
||||
|
||||
describe('charToTile', () =>
|
||||
{
|
||||
it('returns blocked for x', () =>
|
||||
{
|
||||
expect(charToTile('x')).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
|
||||
it('returns h=0 for "0"', () =>
|
||||
{
|
||||
expect(charToTile('0')).toEqual({ h: 0, blocked: false });
|
||||
});
|
||||
|
||||
it('returns h=9 for "9"', () =>
|
||||
{
|
||||
expect(charToTile('9')).toEqual({ h: 9, blocked: false });
|
||||
});
|
||||
|
||||
it('returns h=10 for "a"', () =>
|
||||
{
|
||||
expect(charToTile('a')).toEqual({ h: 10, blocked: false });
|
||||
});
|
||||
|
||||
it('returns h=26 for "q"', () =>
|
||||
{
|
||||
expect(charToTile('q')).toEqual({ h: 26, blocked: false });
|
||||
});
|
||||
|
||||
it('treats uppercase X as blocked', () =>
|
||||
{
|
||||
expect(charToTile('X')).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
|
||||
it('returns blocked for any unknown char (defensive)', () =>
|
||||
{
|
||||
expect(charToTile('?')).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tileToChar', () =>
|
||||
{
|
||||
it('returns x for blocked tile', () =>
|
||||
{
|
||||
expect(tileToChar({ h: 5, blocked: true })).toBe('x');
|
||||
});
|
||||
|
||||
it('returns "0" for h=0 non-blocked', () =>
|
||||
{
|
||||
expect(tileToChar({ h: 0, blocked: false })).toBe('0');
|
||||
});
|
||||
|
||||
it('returns "q" for h=26 non-blocked', () =>
|
||||
{
|
||||
expect(tileToChar({ h: 26, blocked: false })).toBe('q');
|
||||
});
|
||||
|
||||
it('clamps out-of-range h to nearest valid', () =>
|
||||
{
|
||||
expect(tileToChar({ h: -1, blocked: false })).toBe('0');
|
||||
expect(tileToChar({ h: 99, blocked: false })).toBe('q');
|
||||
});
|
||||
|
||||
it('treats NaN h as h=0 on non-blocked tile (does not collapse to blocked)', () =>
|
||||
{
|
||||
expect(tileToChar({ h: NaN, blocked: false })).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTilemap', () =>
|
||||
{
|
||||
it('returns empty grid for empty string', () =>
|
||||
{
|
||||
expect(parseTilemap('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a single row', () =>
|
||||
{
|
||||
expect(parseTilemap('00x0')).toEqual([
|
||||
[
|
||||
{ h: 0, blocked: false },
|
||||
{ h: 0, blocked: false },
|
||||
{ h: 0, blocked: true },
|
||||
{ h: 0, blocked: false }
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses multiple rows separated by \\r', () =>
|
||||
{
|
||||
const raw = '00\rxx\r12';
|
||||
const grid = parseTilemap(raw);
|
||||
expect(grid).toHaveLength(3);
|
||||
expect(grid[0]).toHaveLength(2);
|
||||
expect(grid[1][0].blocked).toBe(true);
|
||||
expect(grid[2][1]).toEqual({ h: 2, blocked: false });
|
||||
});
|
||||
|
||||
it('also accepts \\r\\n as row separator', () =>
|
||||
{
|
||||
const raw = '00\r\nxx';
|
||||
const grid = parseTilemap(raw);
|
||||
expect(grid).toHaveLength(2);
|
||||
expect(grid[1][1].blocked).toBe(true);
|
||||
});
|
||||
|
||||
it('also accepts \\n alone as row separator (textarea normalization)', () =>
|
||||
{
|
||||
const raw = '00\nxq';
|
||||
const grid = parseTilemap(raw);
|
||||
expect(grid).toHaveLength(2);
|
||||
expect(grid[0]).toHaveLength(2);
|
||||
expect(grid[1][1]).toEqual({ h: 26, blocked: false });
|
||||
});
|
||||
|
||||
it('pads short rows with blocked tiles so the grid is rectangular', () =>
|
||||
{
|
||||
const raw = '000\rx';
|
||||
const grid = parseTilemap(raw);
|
||||
expect(grid[1]).toHaveLength(3);
|
||||
expect(grid[1][1]).toEqual({ h: 0, blocked: true });
|
||||
expect(grid[1][2]).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeTilemap', () =>
|
||||
{
|
||||
it('returns empty string for empty grid', () =>
|
||||
{
|
||||
expect(serializeTilemap([])).toBe('');
|
||||
});
|
||||
|
||||
it('serializes a single row with no separator', () =>
|
||||
{
|
||||
const grid = [[
|
||||
{ h: 0, blocked: false },
|
||||
{ h: 1, blocked: false },
|
||||
{ h: 0, blocked: true }
|
||||
]];
|
||||
expect(serializeTilemap(grid)).toBe('01x');
|
||||
});
|
||||
|
||||
it('separates rows with \\r', () =>
|
||||
{
|
||||
const grid = [
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }],
|
||||
[{ h: 0, blocked: true }, { h: 26, blocked: false }]
|
||||
];
|
||||
expect(serializeTilemap(grid)).toBe('00\rxq');
|
||||
});
|
||||
|
||||
it('round-trips parse → serialize', () =>
|
||||
{
|
||||
const raw = '0123\rxxqq\r1234';
|
||||
expect(serializeTilemap(parseTilemap(raw))).toBe(raw);
|
||||
});
|
||||
|
||||
it('jagged-row round-trip normalizes short rows with x-padding', () =>
|
||||
{
|
||||
const raw = '000\rx';
|
||||
expect(serializeTilemap(parseTilemap(raw))).toBe('000\rxxx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Tile } from './types';
|
||||
import { HEIGHT_SCHEME } from './constants';
|
||||
|
||||
// 'x0123456789abcdefghijklmnopq' (28 chars total)
|
||||
const VALID_CHARS = HEIGHT_SCHEME;
|
||||
const HMIN = 0;
|
||||
const HMAX = VALID_CHARS.length - 2; // 26
|
||||
|
||||
export const charToTile = (ch: string): Tile =>
|
||||
{
|
||||
const lower = ch.toLowerCase();
|
||||
const idx = VALID_CHARS.indexOf(lower);
|
||||
if(idx <= 0) return { h: 0, blocked: true };
|
||||
return { h: idx - 1, blocked: false };
|
||||
};
|
||||
|
||||
export const tileToChar = (tile: Tile): string =>
|
||||
{
|
||||
if(tile.blocked) return 'x';
|
||||
const h = Number.isFinite(tile.h)
|
||||
? Math.max(HMIN, Math.min(HMAX, tile.h))
|
||||
: HMIN;
|
||||
return VALID_CHARS.charAt(h + 1);
|
||||
};
|
||||
|
||||
export const parseTilemap = (raw: string): Tile[][] =>
|
||||
{
|
||||
if(!raw) return [];
|
||||
const cleaned = raw.split(/\r\n|\r|\n/).filter(r => r.length > 0);
|
||||
if(cleaned.length === 0) return [];
|
||||
const width = cleaned.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
return cleaned.map(rowStr =>
|
||||
{
|
||||
const cells: Tile[] = [];
|
||||
for(let i = 0; i < width; i++)
|
||||
{
|
||||
cells.push(i < rowStr.length ? charToTile(rowStr.charAt(i)) : { h: 0, blocked: true });
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
};
|
||||
|
||||
export const serializeTilemap = (tiles: Tile[][]): string =>
|
||||
{
|
||||
if(!tiles || tiles.length === 0) return '';
|
||||
return tiles.map(row => row.map(tileToChar).join('')).join('\r');
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { reducer, initialState } from './reducer';
|
||||
import { FloorplanState } from './types';
|
||||
import { defaultEmptyTilemap } from './selectors';
|
||||
|
||||
const stateWith = (tiles: FloorplanState['tiles']): FloorplanState => ({
|
||||
...initialState,
|
||||
tiles
|
||||
});
|
||||
|
||||
describe('reducer — PAINT_TILE', () =>
|
||||
{
|
||||
it('sets tile to {h, blocked: false}', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(2, 2));
|
||||
const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 1, h: 5, source: 'local' });
|
||||
expect(next.tiles[0][1]).toEqual({ h: 5, blocked: false });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
|
||||
it('clamps h to 0..26', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(1, 1));
|
||||
const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 0, h: 99, source: 'local' });
|
||||
expect(next.tiles[0][0].h).toBe(26);
|
||||
});
|
||||
|
||||
it('grows the grid to fit out-of-bounds rows/cols', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(1, 1));
|
||||
const next = reducer(start, { type: 'PAINT_TILE', row: 2, col: 3, h: 0, source: 'local' });
|
||||
expect(next.tiles).toHaveLength(3);
|
||||
expect(next.tiles[2]).toHaveLength(4);
|
||||
expect(next.tiles[2][3]).toEqual({ h: 0, blocked: false });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
|
||||
it('caps growth at MAX_NUM_TILE_PER_AXIS', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(1, 1));
|
||||
const next = reducer(start, { type: 'PAINT_TILE', row: 99, col: 99, h: 0, source: 'local' });
|
||||
expect(next.tiles).toHaveLength(64);
|
||||
expect(next.tiles[0]).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('returns the same reference if no change (idempotent painting)', () =>
|
||||
{
|
||||
const tile = { h: 5, blocked: false };
|
||||
const start = stateWith([[tile]]);
|
||||
const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' });
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — ERASE_TILE', () =>
|
||||
{
|
||||
it('marks tile as blocked', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 5, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'ERASE_TILE', row: 0, col: 0, source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 5, blocked: true });
|
||||
});
|
||||
|
||||
it('is a no-op outside the grid', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(1, 1));
|
||||
const next = reducer(start, { type: 'ERASE_TILE', row: 5, col: 5, source: 'local' });
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — ADJUST_HEIGHT', () =>
|
||||
{
|
||||
it('increments height by 1', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 5, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 6, blocked: false });
|
||||
});
|
||||
|
||||
it('decrements height by 1', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 5, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: -1, source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 4, blocked: false });
|
||||
});
|
||||
|
||||
it('clamps at 26 going up', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 26, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
|
||||
expect(next.tiles[0][0].h).toBe(26);
|
||||
});
|
||||
|
||||
it('clamps at 0 going down', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: -1, source: 'local' });
|
||||
expect(next.tiles[0][0].h).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op on blocked tiles', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 5, blocked: true }]]);
|
||||
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — SET_DOOR', () =>
|
||||
{
|
||||
it('updates door position', () =>
|
||||
{
|
||||
const next = reducer(initialState, { type: 'SET_DOOR', x: 3, y: 4, source: 'local' });
|
||||
expect(next.door).toEqual({ x: 3, y: 4, dir: 2 });
|
||||
});
|
||||
|
||||
it('preserves door direction', () =>
|
||||
{
|
||||
const start = { ...initialState, door: { x: 0, y: 0, dir: 5 as const } };
|
||||
const next = reducer(start, { type: 'SET_DOOR', x: 1, y: 1, source: 'local' });
|
||||
expect(next.door).toEqual({ x: 1, y: 1, dir: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — SET_DOOR_DIR', () =>
|
||||
{
|
||||
it('updates direction', () =>
|
||||
{
|
||||
const next = reducer(initialState, { type: 'SET_DOOR_DIR', dir: 7, source: 'local' });
|
||||
expect(next.door.dir).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — SET_THICKNESS', () =>
|
||||
{
|
||||
it('updates wall only', () =>
|
||||
{
|
||||
const next = reducer(initialState, { type: 'SET_THICKNESS', wall: 3, source: 'local' });
|
||||
expect(next.thickness).toEqual({ wall: 3, floor: 1 });
|
||||
});
|
||||
|
||||
it('updates floor only', () =>
|
||||
{
|
||||
const next = reducer(initialState, { type: 'SET_THICKNESS', floor: 0, source: 'local' });
|
||||
expect(next.thickness).toEqual({ wall: 1, floor: 0 });
|
||||
});
|
||||
|
||||
it('updates both', () =>
|
||||
{
|
||||
const next = reducer(initialState, { type: 'SET_THICKNESS', wall: 2, floor: 3, source: 'local' });
|
||||
expect(next.thickness).toEqual({ wall: 2, floor: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — SET_WALL_HEIGHT', () =>
|
||||
{
|
||||
it('updates wallHeight clamped to 0..16', () =>
|
||||
{
|
||||
expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: 5, source: 'local' }).wallHeight).toBe(5);
|
||||
expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: 99, source: 'local' }).wallHeight).toBe(16);
|
||||
expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: -3, source: 'local' }).wallHeight).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — BRUSH_SET', () =>
|
||||
{
|
||||
it('updates h only', () =>
|
||||
{
|
||||
const next = reducer(initialState, { type: 'BRUSH_SET', h: 10 });
|
||||
expect(next.brush).toEqual({ h: 10, action: 'SET' });
|
||||
});
|
||||
|
||||
it('updates action only', () =>
|
||||
{
|
||||
const next = reducer(initialState, { type: 'BRUSH_SET', action: 'DOOR' });
|
||||
expect(next.brush).toEqual({ h: 0, action: 'DOOR' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — selection', () =>
|
||||
{
|
||||
it('SELECT_ALL marks every non-blocked tile', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }]
|
||||
]);
|
||||
const next = reducer(start, { type: 'SELECT_ALL' });
|
||||
expect(next.selection.size).toBe(3);
|
||||
expect(next.selection.has('0,0')).toBe(true);
|
||||
expect(next.selection.has('0,1')).toBe(false);
|
||||
expect(next.selection.has('1,1')).toBe(true);
|
||||
});
|
||||
|
||||
it('CLEAR_SELECTION empties it', () =>
|
||||
{
|
||||
const start = { ...initialState, selection: new Set(['0,0', '1,1']) as ReadonlySet<`${number},${number}`> };
|
||||
const next = reducer(start, { type: 'CLEAR_SELECTION' });
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('SELECT_RECT marks the rectangle inclusive', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(4, 4));
|
||||
// First populate non-blocked tiles so SELECT_RECT picks them up
|
||||
const populated = {
|
||||
...start,
|
||||
tiles: start.tiles.map(row => row.map(() => ({ h: 0, blocked: false })))
|
||||
} as FloorplanState;
|
||||
const next = reducer(populated, { type: 'SELECT_RECT', from: [ 1, 1 ], to: [ 2, 3 ] });
|
||||
const keys = Array.from(next.selection).sort();
|
||||
expect(keys).toEqual([ '1,1', '1,2', '1,3', '2,1', '2,2', '2,3' ].sort());
|
||||
});
|
||||
|
||||
it('SQUARE_SELECT_TOGGLE flips the flag', () =>
|
||||
{
|
||||
const a = reducer(initialState, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
expect(a.squareSelect).toBe(true);
|
||||
const b = reducer(a, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
expect(b.squareSelect).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — IMPORT_STRING', () =>
|
||||
{
|
||||
it('replaces tilemap with parsed string', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(1, 1));
|
||||
const next = reducer(start, { type: 'IMPORT_STRING', raw: '01\rxq', source: 'local' });
|
||||
expect(next.tiles).toHaveLength(2);
|
||||
expect(next.tiles[0]).toEqual([
|
||||
{ h: 0, blocked: false },
|
||||
{ h: 1, blocked: false }
|
||||
]);
|
||||
expect(next.tiles[1]).toEqual([
|
||||
{ h: 0, blocked: true },
|
||||
{ h: 26, blocked: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('optionally updates door, thickness, wallHeight', () =>
|
||||
{
|
||||
const next = reducer(initialState, {
|
||||
type: 'IMPORT_STRING',
|
||||
raw: '00',
|
||||
door: { x: 5, y: 6, dir: 4 },
|
||||
thickness: { wall: 3, floor: 2 },
|
||||
wallHeight: 8,
|
||||
source: 'local'
|
||||
});
|
||||
expect(next.door).toEqual({ x: 5, y: 6, dir: 4 });
|
||||
expect(next.thickness).toEqual({ wall: 3, floor: 2 });
|
||||
expect(next.wallHeight).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — APPLY_REMOTE_DIFF', () =>
|
||||
{
|
||||
it('applies tile edits without re-broadcasting (source agnostic)', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }]]);
|
||||
const next = reducer(start, {
|
||||
type: 'APPLY_REMOTE_DIFF',
|
||||
diff: { tiles: [{ row: 0, col: 0, h: 7, blocked: false }] },
|
||||
seq: 1,
|
||||
editorUserId: 42
|
||||
});
|
||||
expect(next.tiles[0][0]).toEqual({ h: 7, blocked: false });
|
||||
expect(next.seq).toBe(1);
|
||||
});
|
||||
|
||||
it('records last seq', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }]]);
|
||||
const a = reducer(start, { type: 'APPLY_REMOTE_DIFF', diff: { tiles: [{ row: 0, col: 0, h: 1, blocked: false }] }, seq: 5, editorUserId: 1 });
|
||||
expect(a.seq).toBe(5);
|
||||
});
|
||||
|
||||
it('applies door/thickness/wallHeight from diff', () =>
|
||||
{
|
||||
const next = reducer(initialState, {
|
||||
type: 'APPLY_REMOTE_DIFF',
|
||||
diff: { door: { x: 2, y: 3, dir: 0 }, thickness: { wall: 0, floor: 0 }, wallHeight: 4 },
|
||||
seq: 1,
|
||||
editorUserId: 99
|
||||
});
|
||||
expect(next.door).toEqual({ x: 2, y: 3, dir: 0 });
|
||||
expect(next.thickness).toEqual({ wall: 0, floor: 0 });
|
||||
expect(next.wallHeight).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — APPLY_REMOTE_SNAPSHOT', () =>
|
||||
{
|
||||
it('replaces full state from snapshot', () =>
|
||||
{
|
||||
const next = reducer(initialState, {
|
||||
type: 'APPLY_REMOTE_SNAPSHOT',
|
||||
raw: '01\rxq',
|
||||
door: { x: 1, y: 1, dir: 3 },
|
||||
thickness: { wall: 2, floor: 3 },
|
||||
wallHeight: 9,
|
||||
seq: 100
|
||||
});
|
||||
expect(next.tiles).toHaveLength(2);
|
||||
expect(next.door).toEqual({ x: 1, y: 1, dir: 3 });
|
||||
expect(next.thickness).toEqual({ wall: 2, floor: 3 });
|
||||
expect(next.wallHeight).toBe(9);
|
||||
expect(next.seq).toBe(100);
|
||||
});
|
||||
|
||||
it('clears selection on snapshot apply', () =>
|
||||
{
|
||||
const start = { ...initialState, selection: new Set([ '0,0' ]) as ReadonlySet<`${number},${number}`> };
|
||||
const next = reducer(start, {
|
||||
type: 'APPLY_REMOTE_SNAPSHOT',
|
||||
raw: '0',
|
||||
door: initialState.door,
|
||||
thickness: initialState.thickness,
|
||||
wallHeight: 0,
|
||||
seq: 1
|
||||
});
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { FloorplanAction, FloorplanState, Tile } from './types';
|
||||
import { MAX_NUM_TILE_PER_AXIS, EMPTY_DOOR, MIN_WALL_HEIGHT, MAX_WALL_HEIGHT } from './constants';
|
||||
import { parseTilemap } from './encoding';
|
||||
|
||||
export const initialState: FloorplanState = {
|
||||
tiles: [],
|
||||
door: { ...EMPTY_DOOR },
|
||||
thickness: { wall: 1, floor: 1 },
|
||||
wallHeight: -1,
|
||||
brush: { h: 0, action: 'SET' },
|
||||
selection: new Set<`${number},${number}`>(),
|
||||
squareSelect: false,
|
||||
lease: { holder: null, me: false, expiresAt: null },
|
||||
seq: 0
|
||||
};
|
||||
|
||||
const clampHeight = (h: number): number => Math.max(0, Math.min(26, h | 0));
|
||||
const clamp64 = (n: number): number => Math.max(0, Math.min(MAX_NUM_TILE_PER_AXIS - 1, n | 0));
|
||||
|
||||
const ensureRect = (tiles: Tile[][], rows: number, cols: number): Tile[][] =>
|
||||
{
|
||||
const tRows = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(rows, tiles.length));
|
||||
const tCols = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(cols, tiles[0]?.length ?? 0));
|
||||
if(tRows === tiles.length && (tiles[0]?.length ?? 0) === tCols) return tiles;
|
||||
const next: Tile[][] = [];
|
||||
for(let r = 0; r < tRows; r++)
|
||||
{
|
||||
const src = tiles[r] ?? [];
|
||||
const row: Tile[] = [];
|
||||
for(let c = 0; c < tCols; c++)
|
||||
{
|
||||
row.push(src[c] ?? { h: 0, blocked: true });
|
||||
}
|
||||
next.push(row);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const setTile = (tiles: Tile[][], row: number, col: number, tile: Tile): Tile[][] =>
|
||||
{
|
||||
const current = tiles[row]?.[col];
|
||||
if(current && current.h === tile.h && current.blocked === tile.blocked) return tiles;
|
||||
const next = tiles.map((r, ri) => ri === row ? r.map((t, ci) => ci === col ? tile : t) : r);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const reducer = (state: FloorplanState, action: FloorplanAction): FloorplanState =>
|
||||
{
|
||||
switch(action.type)
|
||||
{
|
||||
case 'PAINT_TILE':
|
||||
{
|
||||
const row = clamp64(action.row);
|
||||
const col = clamp64(action.col);
|
||||
const tiles = ensureRect(state.tiles, row + 1, col + 1);
|
||||
const target = { h: clampHeight(action.h), blocked: false };
|
||||
const next = setTile(tiles, row, col, target);
|
||||
if(next === tiles && tiles === state.tiles) return state;
|
||||
return { ...state, tiles: next };
|
||||
}
|
||||
case 'ERASE_TILE':
|
||||
{
|
||||
const row = action.row | 0;
|
||||
const col = action.col | 0;
|
||||
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
|
||||
const current = state.tiles[row][col];
|
||||
const target = { h: current.h, blocked: true };
|
||||
const next = setTile(state.tiles, row, col, target);
|
||||
if(next === state.tiles) return state;
|
||||
return { ...state, tiles: next };
|
||||
}
|
||||
case 'ADJUST_HEIGHT':
|
||||
{
|
||||
const row = action.row | 0;
|
||||
const col = action.col | 0;
|
||||
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
|
||||
const current = state.tiles[row][col];
|
||||
if(current.blocked) return state;
|
||||
const newH = clampHeight(current.h + action.delta);
|
||||
if(newH === current.h) return state;
|
||||
const next = setTile(state.tiles, row, col, { h: newH, blocked: false });
|
||||
return { ...state, tiles: next };
|
||||
}
|
||||
case 'SET_DOOR':
|
||||
{
|
||||
const x = clamp64(action.x);
|
||||
const y = clamp64(action.y);
|
||||
if(state.door.x === x && state.door.y === y) return state;
|
||||
return { ...state, door: { ...state.door, x, y } };
|
||||
}
|
||||
case 'SET_DOOR_DIR':
|
||||
{
|
||||
if(state.door.dir === action.dir) return state;
|
||||
return { ...state, door: { ...state.door, dir: action.dir } };
|
||||
}
|
||||
case 'SET_THICKNESS':
|
||||
{
|
||||
const wall = action.wall ?? state.thickness.wall;
|
||||
const floor = action.floor ?? state.thickness.floor;
|
||||
if(wall === state.thickness.wall && floor === state.thickness.floor) return state;
|
||||
return { ...state, thickness: { wall, floor } };
|
||||
}
|
||||
case 'SET_WALL_HEIGHT':
|
||||
{
|
||||
const value = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.value | 0));
|
||||
if(value === state.wallHeight) return state;
|
||||
return { ...state, wallHeight: value };
|
||||
}
|
||||
case 'BRUSH_SET':
|
||||
{
|
||||
const h = action.h ?? state.brush.h;
|
||||
const act = action.action ?? state.brush.action;
|
||||
if(h === state.brush.h && act === state.brush.action) return state;
|
||||
return { ...state, brush: { h: clampHeight(h), action: act } };
|
||||
}
|
||||
case 'SELECT_ALL':
|
||||
{
|
||||
const sel = new Set<`${number},${number}`>();
|
||||
for(let r = 0; r < state.tiles.length; r++)
|
||||
{
|
||||
for(let c = 0; c < (state.tiles[r]?.length ?? 0); c++)
|
||||
{
|
||||
if(!state.tiles[r][c].blocked) sel.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
return { ...state, selection: sel };
|
||||
}
|
||||
case 'CLEAR_SELECTION':
|
||||
return state.selection.size === 0 ? state : { ...state, selection: new Set() };
|
||||
case 'SELECT_RECT':
|
||||
{
|
||||
const [ r0, c0 ] = action.from;
|
||||
const [ r1, c1 ] = action.to;
|
||||
const rMin = Math.min(r0, r1), rMax = Math.max(r0, r1);
|
||||
const cMin = Math.min(c0, c1), cMax = Math.max(c0, c1);
|
||||
const sel = new Set<`${number},${number}`>();
|
||||
for(let r = rMin; r <= rMax; r++)
|
||||
{
|
||||
for(let c = cMin; c <= cMax; c++)
|
||||
{
|
||||
if(state.tiles[r]?.[c] && !state.tiles[r][c].blocked) sel.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
return { ...state, selection: sel };
|
||||
}
|
||||
case 'SQUARE_SELECT_TOGGLE':
|
||||
return { ...state, squareSelect: !state.squareSelect };
|
||||
case 'IMPORT_STRING':
|
||||
{
|
||||
const tiles = parseTilemap(action.raw);
|
||||
const next: FloorplanState = { ...state, tiles };
|
||||
if(action.door) next.door = action.door;
|
||||
if(action.thickness) next.thickness = action.thickness;
|
||||
if(action.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0));
|
||||
return next;
|
||||
}
|
||||
case 'APPLY_REMOTE_DIFF':
|
||||
{
|
||||
let next: FloorplanState = { ...state, seq: action.seq };
|
||||
if(action.diff.tiles)
|
||||
{
|
||||
let tiles = next.tiles;
|
||||
for(const e of action.diff.tiles)
|
||||
{
|
||||
tiles = ensureRect(tiles, e.row + 1, e.col + 1);
|
||||
tiles = setTile(tiles, e.row, e.col, { h: clampHeight(e.h), blocked: e.blocked });
|
||||
}
|
||||
next.tiles = tiles;
|
||||
}
|
||||
if(action.diff.door) next.door = action.diff.door;
|
||||
if(action.diff.thickness) next.thickness = action.diff.thickness;
|
||||
if(action.diff.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.diff.wallHeight | 0));
|
||||
return next;
|
||||
}
|
||||
case 'APPLY_REMOTE_SNAPSHOT':
|
||||
{
|
||||
return {
|
||||
...state,
|
||||
tiles: parseTilemap(action.raw),
|
||||
door: action.door,
|
||||
thickness: action.thickness,
|
||||
wallHeight: Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0)),
|
||||
selection: new Set(),
|
||||
seq: action.seq
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
areaCount,
|
||||
brushChar,
|
||||
tileFill,
|
||||
defaultEmptyTilemap
|
||||
} from './selectors';
|
||||
import { HEIGHT_SCHEME } from './constants';
|
||||
|
||||
describe('areaCount', () =>
|
||||
{
|
||||
it('returns zeros for empty grid', () =>
|
||||
{
|
||||
expect(areaCount([])).toEqual({ total: 0, walkable: 0 });
|
||||
});
|
||||
|
||||
it('counts total = walkable when no blocked tiles', () =>
|
||||
{
|
||||
const grid = [
|
||||
[{ h: 0, blocked: false }, { h: 1, blocked: false }],
|
||||
[{ h: 2, blocked: false }, { h: 3, blocked: false }]
|
||||
];
|
||||
expect(areaCount(grid)).toEqual({ total: 4, walkable: 4 });
|
||||
});
|
||||
|
||||
it('excludes blocked from walkable but counts in total', () =>
|
||||
{
|
||||
const grid = [
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
|
||||
[{ h: 2, blocked: false }, { h: 3, blocked: false }]
|
||||
];
|
||||
expect(areaCount(grid)).toEqual({ total: 3, walkable: 3 });
|
||||
});
|
||||
|
||||
it('treats blocked tiles as non-tiles (per existing UI semantics)', () =>
|
||||
{
|
||||
// In the original implementation, height === 'x' was the marker for "not a tile".
|
||||
// total counts placed tiles only (i.e. !blocked), walkable equals total since blocked are excluded.
|
||||
// This matches the legacy calculateArea() behaviour where blocked tiles were skipped entirely.
|
||||
const grid = [
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: false }, { h: 1, blocked: false }, { h: 0, blocked: true }]
|
||||
];
|
||||
expect(areaCount(grid)).toEqual({ total: 2, walkable: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('brushChar', () =>
|
||||
{
|
||||
it('h=0 → "0"', () => expect(brushChar(0)).toBe('0'));
|
||||
it('h=26 → "q"', () => expect(brushChar(26)).toBe('q'));
|
||||
it('clamps below to "0"', () => expect(brushChar(-5)).toBe('0'));
|
||||
it('clamps above to "q"', () => expect(brushChar(99)).toBe('q'));
|
||||
});
|
||||
|
||||
describe('tileFill', () =>
|
||||
{
|
||||
it('returns COLORMAP entry for non-blocked tile', () =>
|
||||
{
|
||||
const fill = tileFill({ h: 0, blocked: false });
|
||||
expect(fill).toBe('#0065ff');
|
||||
});
|
||||
|
||||
it('returns COLORMAP entry for blocked tile', () =>
|
||||
{
|
||||
expect(tileFill({ h: 5, blocked: true })).toBe('#101010');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultEmptyTilemap', () =>
|
||||
{
|
||||
it('returns a rows×cols grid of blocked tiles', () =>
|
||||
{
|
||||
const grid = defaultEmptyTilemap(3, 4);
|
||||
expect(grid).toHaveLength(3);
|
||||
expect(grid[0]).toHaveLength(4);
|
||||
expect(grid[0][0]).toEqual({ h: 0, blocked: true });
|
||||
expect(grid[2][3]).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Tile } from './types';
|
||||
import { HEIGHT_SCHEME, COLORMAP, HEIGHT_BRUSH_MIN, HEIGHT_BRUSH_MAX } from './constants';
|
||||
|
||||
export const areaCount = (tiles: Tile[][]): { total: number; walkable: number } =>
|
||||
{
|
||||
let total = 0;
|
||||
let walkable = 0;
|
||||
for(const row of tiles)
|
||||
{
|
||||
for(const tile of row)
|
||||
{
|
||||
if(tile.blocked) continue;
|
||||
total++;
|
||||
walkable++;
|
||||
}
|
||||
}
|
||||
return { total, walkable };
|
||||
};
|
||||
|
||||
export const brushChar = (h: number): string =>
|
||||
{
|
||||
const clamped = Math.max(HEIGHT_BRUSH_MIN, Math.min(HEIGHT_BRUSH_MAX, h));
|
||||
return HEIGHT_SCHEME.charAt(clamped + 1);
|
||||
};
|
||||
|
||||
export const tileFill = (tile: Tile): string =>
|
||||
{
|
||||
const ch = tile.blocked ? 'x' : HEIGHT_SCHEME.charAt(Math.max(0, Math.min(26, tile.h)) + 1);
|
||||
const hex = (COLORMAP as Record<string, string>)[ch] ?? '101010';
|
||||
return `#${ hex }`;
|
||||
};
|
||||
|
||||
export const defaultEmptyTilemap = (rows: number, cols: number): Tile[][] =>
|
||||
{
|
||||
const grid: Tile[][] = [];
|
||||
for(let r = 0; r < rows; r++)
|
||||
{
|
||||
const row: Tile[] = [];
|
||||
for(let c = 0; c < cols; c++) row.push({ h: 0, blocked: true });
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
export type Tile = { h: number; blocked: boolean };
|
||||
|
||||
export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
export type ThicknessLevel = 0 | 1 | 2 | 3;
|
||||
|
||||
export type Door = { x: number; y: number; dir: EntryDir };
|
||||
|
||||
export type FloorActionMode = 'SET' | 'UNSET' | 'UP' | 'DOWN' | 'DOOR';
|
||||
|
||||
export type Brush = { h: number; action: FloorActionMode };
|
||||
|
||||
export type Selection = ReadonlySet<`${number},${number}`>;
|
||||
|
||||
export type Lease = {
|
||||
holder: number | null;
|
||||
me: boolean;
|
||||
expiresAt: number | null;
|
||||
};
|
||||
|
||||
export type FloorplanState = {
|
||||
tiles: Tile[][];
|
||||
door: Door;
|
||||
thickness: { wall: ThicknessLevel; floor: ThicknessLevel };
|
||||
wallHeight: number;
|
||||
brush: Brush;
|
||||
selection: Selection;
|
||||
squareSelect: boolean;
|
||||
lease: Lease;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export type LocalSource = 'local' | 'remote';
|
||||
|
||||
export type FloorplanAction =
|
||||
| { type: 'PAINT_TILE'; row: number; col: number; h: number; source: LocalSource }
|
||||
| { type: 'ERASE_TILE'; row: number; col: number; source: LocalSource }
|
||||
| { type: 'ADJUST_HEIGHT'; row: number; col: number; delta: 1 | -1; source: LocalSource }
|
||||
| { type: 'SET_DOOR'; x: number; y: number; source: LocalSource }
|
||||
| { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource }
|
||||
| { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource }
|
||||
| { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource }
|
||||
| { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode }
|
||||
| { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] }
|
||||
| { type: 'SELECT_ALL' }
|
||||
| { type: 'CLEAR_SELECTION' }
|
||||
| { type: 'SQUARE_SELECT_TOGGLE' }
|
||||
| { type: 'IMPORT_STRING'; raw: string; door?: Door; thickness?: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight?: number; source: LocalSource }
|
||||
| { type: 'APPLY_REMOTE_DIFF'; diff: { tiles?: Array<{ row: number; col: number; h: number; blocked: boolean }>; door?: Door; thickness?: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight?: number }; seq: number; editorUserId: number }
|
||||
| { type: 'APPLY_REMOTE_SNAPSHOT'; raw: string; door: Door; thickness: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight: number; seq: number };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { FloorplanCanvasSVG } from './FloorplanCanvasSVG';
|
||||
import { initialState } from '../state/reducer';
|
||||
|
||||
describe('FloorplanCanvasSVG', () =>
|
||||
{
|
||||
it('renders one polygon per non-blocked tile', () =>
|
||||
{
|
||||
const state = {
|
||||
...initialState,
|
||||
tiles: [
|
||||
[{ h: 0, blocked: false }, { h: 1, blocked: true }],
|
||||
[{ h: 2, blocked: false }, { h: 3, blocked: false }]
|
||||
]
|
||||
};
|
||||
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ () => {} } />);
|
||||
// 3 non-blocked tiles → 3 base polygons (plus possibly selection/door extras)
|
||||
const polys = container.querySelectorAll('polygon');
|
||||
expect(polys.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('renders door marker on the door tile', () =>
|
||||
{
|
||||
const state = {
|
||||
...initialState,
|
||||
tiles: [[{ h: 0, blocked: false }, { h: 0, blocked: false }]],
|
||||
door: { x: 1, y: 0, dir: 2 as const }
|
||||
};
|
||||
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ () => {} } />);
|
||||
expect(container.querySelector('[data-testid="door-marker"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('forwards pointer events to a tool dispatch (PAINT_TILE with brush)', () =>
|
||||
{
|
||||
const state = {
|
||||
...initialState,
|
||||
tiles: [[{ h: 0, blocked: false }]],
|
||||
brush: { h: 0, action: 'SET' as const }
|
||||
};
|
||||
const dispatch = vi.fn();
|
||||
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />);
|
||||
const svg = container.querySelector('svg') as SVGSVGElement;
|
||||
// jsdom getBoundingClientRect returns zeros; we need to stub it so projection works.
|
||||
svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) });
|
||||
fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 });
|
||||
expect(dispatch).toHaveBeenCalled();
|
||||
const call = dispatch.mock.calls[0][0];
|
||||
expect(call.type).toBe('PAINT_TILE');
|
||||
});
|
||||
|
||||
it('zoom in/out buttons adjust the viewBox', () =>
|
||||
{
|
||||
const { container } = render(<FloorplanCanvasSVG state={ initialState } dispatch={ () => {} } />);
|
||||
const svg = container.querySelector('svg') as SVGSVGElement;
|
||||
const initialVB = svg.getAttribute('viewBox');
|
||||
fireEvent.click(container.querySelector('[data-testid="zoom-in"]') as Element);
|
||||
expect(svg.getAttribute('viewBox')).not.toBe(initialVB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
|
||||
import { FaCrosshairs, FaSearchMinus, FaSearchPlus } from 'react-icons/fa';
|
||||
import { FloorplanAction, FloorplanState } from '../state/types';
|
||||
import { FloorplanTile } from './FloorplanTile';
|
||||
import { tileToScreen, usePointerToTile } from '../hooks/usePointerToTile';
|
||||
import { useTool } from '../hooks/useTool';
|
||||
import { TILE_SIZE, MAX_NUM_TILE_PER_AXIS } from '../state/constants';
|
||||
|
||||
type Props = {
|
||||
state: FloorplanState;
|
||||
dispatch: Dispatch<FloorplanAction>;
|
||||
};
|
||||
|
||||
const VIEWBOX_W = 2048;
|
||||
const VIEWBOX_H = (MAX_NUM_TILE_PER_AXIS * TILE_SIZE) / 2;
|
||||
|
||||
const ZOOM_MIN = 0.4;
|
||||
const ZOOM_MAX = 6;
|
||||
const ZOOM_STEP = 0.2;
|
||||
// Slack around the room bounding box when auto-fitting, so the tiles
|
||||
// don't sit flush against the canvas edge.
|
||||
const FIT_PADDING = TILE_SIZE * 2;
|
||||
|
||||
const clampZoom = (z: number): number => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
|
||||
|
||||
/**
|
||||
* Compute the screen-space bounding box of the painted (= non-
|
||||
* blocked) tiles. Returns `null` if the room is fully blocked /
|
||||
* empty — caller can fall back to the centered default view.
|
||||
*
|
||||
* tileToScreen returns the TOP corner of the iso diamond; we
|
||||
* inflate by half a tile in every direction so the diamond's
|
||||
* extremities (left/right/bottom points) are included.
|
||||
*/
|
||||
const computeRoomBounds = (state: FloorplanState): { x: number; y: number; w: number; h: number } | null =>
|
||||
{
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
let found = false;
|
||||
|
||||
for(let r = 0; r < state.tiles.length; r++)
|
||||
{
|
||||
const row = state.tiles[r];
|
||||
|
||||
if(!row) continue;
|
||||
|
||||
for(let c = 0; c < row.length; c++)
|
||||
{
|
||||
const tile = row[c];
|
||||
|
||||
if(!tile || tile.blocked) continue;
|
||||
|
||||
const [ x, y ] = tileToScreen(r, c);
|
||||
const tileLeft = x - TILE_SIZE / 2;
|
||||
const tileRight = x + TILE_SIZE / 2;
|
||||
const tileTop = y;
|
||||
const tileBottom = y + TILE_SIZE / 2;
|
||||
|
||||
if(tileLeft < minX) minX = tileLeft;
|
||||
if(tileRight > maxX) maxX = tileRight;
|
||||
if(tileTop < minY) minY = tileTop;
|
||||
if(tileBottom > maxY) maxY = tileBottom;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(!found) return null;
|
||||
|
||||
return {
|
||||
x: minX - FIT_PADDING,
|
||||
y: minY - FIT_PADDING,
|
||||
w: (maxX - minX) + FIT_PADDING * 2,
|
||||
h: (maxY - minY) + FIT_PADDING * 2
|
||||
};
|
||||
};
|
||||
|
||||
export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch }) =>
|
||||
{
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [ zoom, setZoom ] = useState(1);
|
||||
const [ pan, setPan ] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [ isPanning, setIsPanning ] = useState(false);
|
||||
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
// First-paint flag: once we've seen a non-empty room we
|
||||
// auto-fit (zoom in/out until the room fills the canvas with
|
||||
// a small margin) exactly once. Manual zoom/pan afterwards is
|
||||
// preserved.
|
||||
const centeredRef = useRef(false);
|
||||
|
||||
const roomBounds = useMemo(() => computeRoomBounds(state), [ state.tiles ]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Pan a given zoom level so the room centre sits in the
|
||||
// viewport centre. With zoom kept, the formula reduces to
|
||||
// `roomCenter - VIEWBOX_center` because the (VIEWBOX - visible)
|
||||
// / 2 base offset terms cancel.
|
||||
const centerPanForRoom = useCallback((): { x: number; y: number } | null =>
|
||||
{
|
||||
if(!roomBounds) return null;
|
||||
return {
|
||||
x: roomBounds.x + roomBounds.w / 2 - VIEWBOX_W / 2,
|
||||
y: roomBounds.y + roomBounds.h / 2 - VIEWBOX_H / 2
|
||||
};
|
||||
}, [ roomBounds ]);
|
||||
|
||||
// Fit-to-room: zooms IN/OUT until the whole room is visible
|
||||
// (with a 5 % margin), then centres the pan. This is the
|
||||
// default view — running on first paint and on every click of
|
||||
// the %% / 'reset' label.
|
||||
const fitToRoom = useCallback(() =>
|
||||
{
|
||||
if(!roomBounds) return;
|
||||
const zoomFitX = VIEWBOX_W / roomBounds.w;
|
||||
const zoomFitY = VIEWBOX_H / roomBounds.h;
|
||||
const targetZoom = clampZoom(Math.min(zoomFitX, zoomFitY) * 0.95);
|
||||
const next = centerPanForRoom();
|
||||
if(!next) return;
|
||||
setZoom(targetZoom);
|
||||
setPan(next);
|
||||
}, [ roomBounds, centerPanForRoom ]);
|
||||
|
||||
// Auto-fit the FIRST time we see a non-empty room (typically
|
||||
// right after the server-driven load). The literal 100 % zoom
|
||||
// leaves too much empty space around small rooms, so the
|
||||
// 'default view' is fit-to-room (~95 % of the smaller axis
|
||||
// so tiles don't sit flush against the edge). The user's
|
||||
// subsequent manual zoom / pan adjustments are preserved.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(centeredRef.current) return;
|
||||
if(!roomBounds) return;
|
||||
centeredRef.current = true;
|
||||
fitToRoom();
|
||||
}, [ roomBounds, fitToRoom ]);
|
||||
|
||||
const visW = VIEWBOX_W / zoom;
|
||||
const visH = VIEWBOX_H / zoom;
|
||||
const baseX = (VIEWBOX_W - visW) / 2;
|
||||
const baseY = (VIEWBOX_H - visH) / 2;
|
||||
const viewX = baseX + pan.x;
|
||||
const viewY = baseY + pan.y;
|
||||
const viewBox = `${ viewX } ${ viewY } ${ visW } ${ visH }`;
|
||||
|
||||
const projection = usePointerToTile(svgRef, { width: visW, height: visH, x: viewX, y: viewY });
|
||||
const tool = useTool(state, dispatch, projection);
|
||||
|
||||
const rows = useMemo(() => state.tiles.map((row, r) =>
|
||||
{
|
||||
const cells = row.map((tile, c) =>
|
||||
{
|
||||
const isDoor = state.door.x === c && state.door.y === r && !tile.blocked;
|
||||
const selected = state.selection.has(`${ r },${ c }`);
|
||||
return <FloorplanTile key={ `${ r }-${ c }` } row={ r } col={ c } tile={ tile } selected={ selected } isDoor={ isDoor } />;
|
||||
});
|
||||
return <g key={ `row-${ r }` }>{ cells }</g>;
|
||||
}), [ state.tiles, state.door.x, state.door.y, state.selection ]);
|
||||
|
||||
const zoomIn = useCallback(() => setZoom(z => clampZoom(z + ZOOM_STEP)), []);
|
||||
const zoomOut = useCallback(() => setZoom(z => clampZoom(z - ZOOM_STEP)), []);
|
||||
// The %% label button restores the default view: fit-to-room
|
||||
// (the same auto-fit that runs on first paint). Clicking it
|
||||
// after manual zoom always gets you back to "room fills the
|
||||
// canvas, room centred".
|
||||
const resetView = useCallback(() =>
|
||||
{
|
||||
fitToRoom();
|
||||
}, [ fitToRoom ]);
|
||||
|
||||
const onWheel = useCallback((e: WheelEvent<SVGSVGElement>) =>
|
||||
{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
e.preventDefault();
|
||||
setZoom(z => clampZoom(z + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)));
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const onMove = (e: PointerEvent) =>
|
||||
{
|
||||
const start = panStartRef.current;
|
||||
if(!start) return;
|
||||
const dx = e.clientX - start.x;
|
||||
const dy = e.clientY - start.y;
|
||||
const rect = svgRef.current?.getBoundingClientRect();
|
||||
if(!rect) return;
|
||||
const scale = visW / rect.width;
|
||||
setPan({
|
||||
x: start.panX - dx * scale,
|
||||
y: start.panY - dy * scale
|
||||
});
|
||||
};
|
||||
const onUp = () =>
|
||||
{
|
||||
panStartRef.current = null;
|
||||
setIsPanning(false);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
return () =>
|
||||
{
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
}, [ visW ]);
|
||||
|
||||
const isPanGesture = (e: ReactPointerEvent): boolean => e.button === 1 || (e.button === 0 && e.shiftKey);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<svg
|
||||
ref={ svgRef }
|
||||
viewBox={ viewBox }
|
||||
className={ `w-full h-full select-none rounded-md border border-zinc-300 bg-[url('@/assets/images/floorplaneditor/canvas_floor_pattern.png')] bg-repeat [image-rendering:pixelated] ${ isPanning ? 'cursor-grabbing' : '' }` }
|
||||
onWheel={ onWheel }
|
||||
onPointerDown={ e =>
|
||||
{
|
||||
if(isPanGesture(e))
|
||||
{
|
||||
e.preventDefault();
|
||||
panStartRef.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
|
||||
setIsPanning(true);
|
||||
return;
|
||||
}
|
||||
tool.onPointerDown(e);
|
||||
} }
|
||||
onPointerMove={ e =>
|
||||
{
|
||||
if(panStartRef.current) return;
|
||||
tool.onPointerMove(e);
|
||||
} }
|
||||
onPointerUp={ e =>
|
||||
{
|
||||
if(panStartRef.current) return;
|
||||
tool.onPointerUp(e);
|
||||
} }
|
||||
>
|
||||
{ rows }
|
||||
</svg>
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1 rounded-md bg-white/95 border border-zinc-300 shadow-sm px-1 py-1 text-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-out"
|
||||
title="Riduci (Ctrl+rotellina)"
|
||||
className="w-7 h-7 flex items-center justify-center rounded hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={ zoom <= ZOOM_MIN + 1e-3 }
|
||||
onClick={ zoomOut }
|
||||
>
|
||||
<FaSearchMinus size={ 12 } />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-reset"
|
||||
title="Inquadra la stanza"
|
||||
className="px-2 h-7 min-w-[3rem] flex items-center justify-center rounded hover:bg-zinc-100 text-xs font-bold tabular-nums"
|
||||
onClick={ resetView }
|
||||
>
|
||||
{ Math.round(zoom * 100) }%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-in"
|
||||
title="Ingrandisci (Ctrl+rotellina)"
|
||||
className="w-7 h-7 flex items-center justify-center rounded hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={ zoom >= ZOOM_MAX - 1e-3 }
|
||||
onClick={ zoomIn }
|
||||
>
|
||||
<FaSearchPlus size={ 12 } />
|
||||
</button>
|
||||
<span className="w-px h-5 bg-zinc-300 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-recenter"
|
||||
title="Centra sulla stanza (mantiene lo zoom)"
|
||||
className="w-7 h-7 flex items-center justify-center rounded hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={ !roomBounds }
|
||||
onClick={ () =>
|
||||
{
|
||||
const next = centerPanForRoom();
|
||||
if(next) setPan(next);
|
||||
} }
|
||||
>
|
||||
<FaCrosshairs size={ 12 } />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,179 +0,0 @@
|
||||
import { GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { FaPlus, FaMinus } from 'react-icons/fa';
|
||||
import { SendMessageComposer } from '../../../api';
|
||||
import { Base, Column, ColumnProps } from '../../../common';
|
||||
import { useMessageEvent } from '../../../hooks';
|
||||
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
|
||||
import { FloorplanEditor } from '@nitrots/nitro-renderer';
|
||||
|
||||
interface FloorplanCanvasViewProps extends ColumnProps
|
||||
{
|
||||
}
|
||||
|
||||
export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
|
||||
{
|
||||
const { gap = 1, children = null, ...rest } = props;
|
||||
const [ occupiedTilesReceived, setOccupiedTilesReceived ] = useState(false);
|
||||
const [ entryTileReceived, setEntryTileReceived ] = useState(false);
|
||||
const [ zoomLevel, setZoomLevel ] = useState(1.0);
|
||||
const { originalFloorplanSettings = null, setOriginalFloorplanSettings = null, setVisualizationSettings = null } = useFloorplanEditorContext();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const canvasWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setOriginalFloorplanSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.reservedTiles = parser.blockedTilesMap;
|
||||
|
||||
FloorplanEditor.instance.setTilemap(newValue.tilemap, newValue.reservedTiles);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
setOccupiedTilesReceived(true);
|
||||
|
||||
elementRef.current.scrollTo((FloorplanEditor.instance.renderer.canvas.width / 3), 0);
|
||||
});
|
||||
|
||||
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setOriginalFloorplanSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.entryPoint = [ parser.x, parser.y ];
|
||||
newValue.entryPointDir = parser.direction;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
setVisualizationSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.entryPointDir = parser.direction;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
FloorplanEditor.instance.doorLocation = { x: parser.x, y: parser.y };
|
||||
|
||||
setEntryTileReceived(true);
|
||||
});
|
||||
|
||||
const onPointerEvent = (event: PointerEvent) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
|
||||
switch(event.type)
|
||||
{
|
||||
case 'pointerout':
|
||||
case 'pointerup':
|
||||
FloorplanEditor.instance.onPointerRelease(event);
|
||||
break;
|
||||
case 'pointerdown':
|
||||
FloorplanEditor.instance.onPointerDown(event);
|
||||
break;
|
||||
case 'pointermove':
|
||||
FloorplanEditor.instance.onPointerMove(event);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const zoomIn = () => setZoomLevel(prev => Math.min(prev + 0.25, 2.0));
|
||||
const zoomOut = () => setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return () =>
|
||||
{
|
||||
FloorplanEditor.instance.clear();
|
||||
|
||||
setVisualizationSettings(prevValue =>
|
||||
{
|
||||
return {
|
||||
wallHeight: originalFloorplanSettings.wallHeight,
|
||||
thicknessWall: originalFloorplanSettings.thicknessWall,
|
||||
thicknessFloor: originalFloorplanSettings.thicknessFloor,
|
||||
entryPointDir: prevValue.entryPointDir
|
||||
};
|
||||
});
|
||||
};
|
||||
}, [ originalFloorplanSettings.thicknessFloor, originalFloorplanSettings.thicknessWall, originalFloorplanSettings.wallHeight, setVisualizationSettings ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!entryTileReceived || !occupiedTilesReceived) return;
|
||||
|
||||
FloorplanEditor.instance.renderTiles();
|
||||
}, [ entryTileReceived, occupiedTilesReceived ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
SendMessageComposer(new GetRoomEntryTileMessageComposer());
|
||||
SendMessageComposer(new GetOccupiedTilesMessageComposer());
|
||||
|
||||
const currentElement = elementRef.current;
|
||||
|
||||
if(!currentElement) return;
|
||||
|
||||
const wrapper = canvasWrapperRef.current;
|
||||
|
||||
if(wrapper) wrapper.appendChild(FloorplanEditor.instance.renderer.canvas);
|
||||
|
||||
currentElement.addEventListener('pointerup', onPointerEvent);
|
||||
currentElement.addEventListener('pointerout', onPointerEvent);
|
||||
currentElement.addEventListener('pointerdown', onPointerEvent);
|
||||
currentElement.addEventListener('pointermove', onPointerEvent);
|
||||
|
||||
return () =>
|
||||
{
|
||||
if(currentElement)
|
||||
{
|
||||
currentElement.removeEventListener('pointerup', onPointerEvent);
|
||||
currentElement.removeEventListener('pointerout', onPointerEvent);
|
||||
currentElement.removeEventListener('pointerdown', onPointerEvent);
|
||||
currentElement.removeEventListener('pointermove', onPointerEvent);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Column gap={ gap } { ...rest } className="relative flex-1">
|
||||
<Base overflow="auto" innerRef={ elementRef } className="flex-1">
|
||||
<div
|
||||
ref={ canvasWrapperRef }
|
||||
style={ {
|
||||
transform: `scale(${ zoomLevel })`,
|
||||
transformOrigin: '0 0'
|
||||
} }
|
||||
/>
|
||||
</Base>
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1 z-10">
|
||||
<button
|
||||
className="w-[28px] h-[28px] flex items-center justify-center rounded bg-[#1e7295] text-white border border-transparent shadow cursor-pointer hover:brightness-110"
|
||||
onClick={ zoomIn }
|
||||
title="Zoom in"
|
||||
>
|
||||
<FaPlus size={ 10 } />
|
||||
</button>
|
||||
<button
|
||||
className="w-[28px] h-[28px] flex items-center justify-center rounded bg-[#1e7295] text-white border border-transparent shadow cursor-pointer hover:brightness-110"
|
||||
onClick={ zoomOut }
|
||||
title="Zoom out"
|
||||
>
|
||||
<FaMinus size={ 10 } />
|
||||
</button>
|
||||
</div>
|
||||
{ children }
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { FloorplanHeightPicker } from './FloorplanHeightPicker';
|
||||
|
||||
describe('FloorplanHeightPicker', () =>
|
||||
{
|
||||
it('renders 27 swatches', () =>
|
||||
{
|
||||
const { container } = render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ () => {} } />);
|
||||
const swatches = container.querySelectorAll('[data-testid^="swatch-"]');
|
||||
expect(swatches).toHaveLength(27);
|
||||
});
|
||||
|
||||
it('clicking a swatch fires onSelect with its height index', () =>
|
||||
{
|
||||
const onSelect = vi.fn();
|
||||
const { container } = render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
|
||||
fireEvent.click(container.querySelector('[data-testid="swatch-5"]') as Element);
|
||||
expect(onSelect).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('marks the selected swatch with data-selected', () =>
|
||||
{
|
||||
const { container } = render(<FloorplanHeightPicker selectedH={ 12 } onSelect={ () => {} } />);
|
||||
expect(container.querySelector('[data-testid="swatch-12"]')?.getAttribute('data-selected')).toBe('true');
|
||||
expect(container.querySelector('[data-testid="swatch-0"]')?.getAttribute('data-selected')).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FC } from 'react';
|
||||
import { HEIGHT_BRUSH_MAX, HEIGHT_BRUSH_MIN } from '../state/constants';
|
||||
import { tileFill } from '../state/selectors';
|
||||
|
||||
type Props = {
|
||||
selectedH: number;
|
||||
onSelect: (h: number) => void;
|
||||
};
|
||||
|
||||
const SWATCH_W = 20;
|
||||
const SWATCH_H = 14;
|
||||
|
||||
export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||
{
|
||||
const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1;
|
||||
const totalH = count * SWATCH_H;
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs">{ selectedH }</span>
|
||||
<svg
|
||||
width={ SWATCH_W }
|
||||
height={ totalH }
|
||||
viewBox={ `0 0 ${ SWATCH_W } ${ totalH }` }
|
||||
className="shrink-0 select-none"
|
||||
role="listbox"
|
||||
aria-label="Brush height"
|
||||
>
|
||||
{ Array.from({ length: count }, (_, i) =>
|
||||
{
|
||||
const h = HEIGHT_BRUSH_MAX - i;
|
||||
const y = i * SWATCH_H;
|
||||
const fill = tileFill({ h, blocked: false });
|
||||
const isSelected = selectedH === h;
|
||||
return (
|
||||
<rect
|
||||
key={ h }
|
||||
data-testid={ `swatch-${ h }` }
|
||||
data-selected={ isSelected ? 'true' : 'false' }
|
||||
x={ 0 }
|
||||
y={ y }
|
||||
width={ SWATCH_W }
|
||||
height={ SWATCH_H }
|
||||
fill={ fill }
|
||||
stroke={ isSelected ? '#fff' : 'rgba(0,0,0,0.3)' }
|
||||
strokeWidth={ isSelected ? 2 : 0.5 }
|
||||
onClick={ () => onSelect(h) }
|
||||
style={ { cursor: 'pointer' } }
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
import { COLORMAP, FloorAction, HEIGHT_SCHEME } from '@nitrots/nitro-renderer';
|
||||
import { FloorplanEditor } from '@nitrots/nitro-renderer';
|
||||
import { Column, Text } from '../../../common';
|
||||
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
|
||||
|
||||
const colormap = COLORMAP as Record<string, string>;
|
||||
|
||||
export const FloorplanHeightSelector: FC<{}> = () =>
|
||||
{
|
||||
const { floorHeight, setFloorHeight, setFloorAction } = useFloorplanEditorContext();
|
||||
|
||||
const onSelectHeight = (height: number) =>
|
||||
{
|
||||
setFloorHeight(height);
|
||||
setFloorAction(FloorAction.SET);
|
||||
|
||||
FloorplanEditor.instance.actionSettings.currentAction = FloorAction.SET;
|
||||
FloorplanEditor.instance.actionSettings.currentHeight = height.toString(36);
|
||||
};
|
||||
|
||||
const heights: number[] = [];
|
||||
|
||||
for(let i = 26; i >= 0; i--) heights.push(i);
|
||||
|
||||
return (
|
||||
<Column className="h-full w-[30px] min-w-[30px] select-none">
|
||||
<Text bold small center>{ floorHeight }</Text>
|
||||
<div className="flex flex-col flex-1 rounded overflow-hidden border-2 border-muted">
|
||||
{ heights.map(h =>
|
||||
{
|
||||
const char = HEIGHT_SCHEME[h + 1];
|
||||
const color = colormap[char] || '101010';
|
||||
const isActive = (floorHeight === h);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ h }
|
||||
className="flex-1 cursor-pointer relative flex items-center justify-center"
|
||||
style={ {
|
||||
backgroundColor: `#${ color }`,
|
||||
outline: isActive ? '2px solid #fff' : 'none',
|
||||
outlineOffset: '-2px',
|
||||
zIndex: isActive ? 1 : 0
|
||||
} }
|
||||
onClick={ () => onSelectHeight(h) }
|
||||
title={ `${ h }` }
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { fireEvent, render, cleanup } from '@testing-library/react';
|
||||
import { FloorplanImportExport } from './FloorplanImportExport';
|
||||
import { initialState } from '../state/reducer';
|
||||
|
||||
describe('FloorplanImportExport', () =>
|
||||
{
|
||||
afterEach(() => cleanup());
|
||||
it('shows serialized tilemap of current state in textarea', () =>
|
||||
{
|
||||
const state = {
|
||||
...initialState,
|
||||
tiles: [
|
||||
[{ h: 0, blocked: false }, { h: 1, blocked: false }],
|
||||
[{ h: 0, blocked: true }, { h: 2, blocked: false }]
|
||||
]
|
||||
};
|
||||
render(<FloorplanImportExport state={ state } dispatch={ () => {} } onClose={ () => {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />);
|
||||
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(ta).toBeTruthy();
|
||||
// Textarea normalizes \r to \n
|
||||
expect(ta.value).toBe('01\nx2');
|
||||
});
|
||||
|
||||
it('clicking Load dispatches IMPORT_STRING with textarea content', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
render(<FloorplanImportExport state={ initialState } dispatch={ dispatch } onClose={ () => {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />);
|
||||
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(ta).toBeTruthy();
|
||||
// Textarea normalizes \r to \n
|
||||
fireEvent.change(ta, { target: { value: 'xq\n00' } });
|
||||
const button = document.querySelector('[data-testid="import-load"]') as HTMLButtonElement;
|
||||
expect(button).toBeTruthy();
|
||||
fireEvent.click(button);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'IMPORT_STRING', raw: 'xq\n00', source: 'local' });
|
||||
});
|
||||
|
||||
it('clicking Save invokes onSaveFromText with textarea content', () =>
|
||||
{
|
||||
const onSaveFromText = vi.fn();
|
||||
render(<FloorplanImportExport state={ initialState } dispatch={ () => {} } onClose={ () => {} } onSaveFromText={ onSaveFromText } onRevertText={ () => '' } />);
|
||||
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
fireEvent.change(ta, { target: { value: '00\n01' } });
|
||||
const saveBtn = document.querySelector('[data-testid="import-save"]') as HTMLButtonElement;
|
||||
fireEvent.click(saveBtn);
|
||||
expect(onSaveFromText).toHaveBeenCalledWith('00\n01');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Dispatch, FC, useState } from 'react';
|
||||
import { LocalizeText } from '../../../api';
|
||||
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common';
|
||||
import { FloorplanAction, FloorplanState } from '../state/types';
|
||||
import { serializeTilemap } from '../state/encoding';
|
||||
|
||||
type Props = {
|
||||
state: FloorplanState;
|
||||
dispatch: Dispatch<FloorplanAction>;
|
||||
onClose: () => void;
|
||||
onSaveFromText: (raw: string) => void;
|
||||
onRevertText: () => string;
|
||||
};
|
||||
|
||||
export const FloorplanImportExport: FC<Props> = ({ state, dispatch, onClose, onSaveFromText, onRevertText }) =>
|
||||
{
|
||||
const [ raw, setRaw ] = useState(() => serializeTilemap(state.tiles));
|
||||
|
||||
const load = () =>
|
||||
{
|
||||
dispatch({ type: 'IMPORT_STRING', raw, source: 'local' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = () =>
|
||||
{
|
||||
onSaveFromText(raw);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const revert = () =>
|
||||
{
|
||||
setRaw(onRevertText());
|
||||
};
|
||||
|
||||
return (
|
||||
<NitroCardView uniqueKey="floorplan-import-export" theme="primary-slim" className="w-[630px] h-[475px]">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.import.export') } onCloseClick={ onClose } />
|
||||
<NitroCardContentView className="flex flex-col gap-2">
|
||||
<textarea
|
||||
className="form-control w-full flex-1 font-mono"
|
||||
value={ raw }
|
||||
onChange={ e => setRaw(e.target.value) }
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button data-testid="import-revert" onClick={ revert }>Annulla</Button>
|
||||
<Button data-testid="import-load" onClick={ load }>Carica</Button>
|
||||
<Button data-testid="import-save" onClick={ save }>{ LocalizeText('floor.plan.editor.save') }</Button>
|
||||
</div>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../api';
|
||||
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common';
|
||||
import { ConvertTileMapToString } from '@nitrots/nitro-renderer';
|
||||
import { convertNumbersForSaving } from '@nitrots/nitro-renderer';
|
||||
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
|
||||
|
||||
interface FloorplanImportExportViewProps
|
||||
{
|
||||
onCloseClick(): void;
|
||||
}
|
||||
|
||||
export const FloorplanImportExportView: FC<FloorplanImportExportViewProps> = props =>
|
||||
{
|
||||
const { onCloseClick = null } = props;
|
||||
const [ map, setMap ] = useState<string>('');
|
||||
const { originalFloorplanSettings = null } = useFloorplanEditorContext();
|
||||
|
||||
const saveFloorChanges = () =>
|
||||
{
|
||||
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
|
||||
map.split('\n').join('\r'),
|
||||
originalFloorplanSettings.entryPoint[0],
|
||||
originalFloorplanSettings.entryPoint[1],
|
||||
originalFloorplanSettings.entryPointDir,
|
||||
convertNumbersForSaving(originalFloorplanSettings.thicknessWall),
|
||||
convertNumbersForSaving(originalFloorplanSettings.thicknessFloor),
|
||||
originalFloorplanSettings.wallHeight - 1
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// changed from UseMountEffect
|
||||
setMap(ConvertTileMapToString(originalFloorplanSettings.tilemap));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NitroCardView theme="primary-slim" className="w-[630px] h-[475px]">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.import.export') } onCloseClick={ onCloseClick } />
|
||||
<NitroCardContentView>
|
||||
<textarea className="h-100" value={ map } onChange={ event => setMap(event.target.value) } />
|
||||
<Flex justifyContent="between">
|
||||
<Button onClick={ event => setMap(ConvertTileMapToString(originalFloorplanSettings.tilemap)) }>
|
||||
{ LocalizeText('floor.plan.editor.revert.to.last.received.map') }
|
||||
</Button>
|
||||
<Button onClick={ saveFloorChanges }>
|
||||
{ LocalizeText('floor.plan.editor.save') }
|
||||
</Button>
|
||||
</Flex>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { fireEvent, render, cleanup } from '@testing-library/react';
|
||||
import { FloorplanOptionsPanel } from './FloorplanOptionsPanel';
|
||||
import { initialState } from '../state/reducer';
|
||||
|
||||
describe('FloorplanOptionsPanel', () =>
|
||||
{
|
||||
afterEach(() => cleanup());
|
||||
it('clicking entry direction cycles 0..7', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const state = { ...initialState, door: { x: 0, y: 0, dir: 2 as const } };
|
||||
const { getByTestId } = render(<FloorplanOptionsPanel state={ state } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('entry-dir'));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_DOOR_DIR', dir: 3, source: 'local' });
|
||||
});
|
||||
|
||||
it('wraps from 7 back to 0', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const state = { ...initialState, door: { x: 0, y: 0, dir: 7 as const } };
|
||||
const { getByTestId } = render(<FloorplanOptionsPanel state={ state } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('entry-dir'));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_DOOR_DIR', dir: 0, source: 'local' });
|
||||
});
|
||||
|
||||
it('wall thickness button dispatches SET_THICKNESS', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanOptionsPanel state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('wall-thickness-3'));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_THICKNESS', wall: 3, source: 'local' });
|
||||
});
|
||||
|
||||
it('floor thickness button dispatches SET_THICKNESS', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanOptionsPanel state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('floor-thickness-0'));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_THICKNESS', floor: 0, source: 'local' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Dispatch, FC } from 'react';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../api';
|
||||
import { Base, Flex, Text } from '../../../common';
|
||||
import { EntryDir, FloorplanAction, FloorplanState, ThicknessLevel } from '../state/types';
|
||||
|
||||
type Props = {
|
||||
state: FloorplanState;
|
||||
dispatch: Dispatch<FloorplanAction>;
|
||||
};
|
||||
|
||||
const THICKNESS_LEVELS: ThicknessLevel[] = [ 0, 1, 2, 3 ];
|
||||
const THICKNESS_NAMES = [ 'thinnest', 'thin', 'normal', 'thick' ] as const;
|
||||
|
||||
const rotateDir = (dir: EntryDir, step: 1 | -1): EntryDir =>
|
||||
(((dir + step + 8) & 7)) as EntryDir;
|
||||
|
||||
export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
|
||||
{
|
||||
const setDir = (next: EntryDir) => dispatch({ type: 'SET_DOOR_DIR', dir: next, source: 'local' });
|
||||
const setWall = (t: ThicknessLevel) => dispatch({ type: 'SET_THICKNESS', wall: t, source: 'local' });
|
||||
const setFloor = (t: ThicknessLevel) => dispatch({ type: 'SET_THICKNESS', floor: t, source: 'local' });
|
||||
|
||||
return (
|
||||
<Flex gap={ 3 } alignItems="center" className="py-1">
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Text bold small className="text-zinc-700">{ LocalizeText('floor.plan.editor.enter.direction') }</Text>
|
||||
<Flex alignItems="center" gap={ 0 } className="rounded border border-zinc-300 bg-white overflow-hidden">
|
||||
<Base
|
||||
data-testid="entry-dir-prev"
|
||||
pointer
|
||||
title="Ruota a sinistra"
|
||||
className="w-7 h-9 flex items-center justify-center text-zinc-600 hover:bg-zinc-100"
|
||||
onClick={ () => setDir(rotateDir(state.door.dir, -1)) }
|
||||
>
|
||||
<FaChevronLeft size={ 12 } />
|
||||
</Base>
|
||||
<Base
|
||||
data-testid="entry-dir"
|
||||
pointer
|
||||
title={ `Direzione ${ state.door.dir }/7 (click per ruotare)` }
|
||||
className={ `nitro-icon icon-door-direction-${ state.door.dir } mx-1` }
|
||||
onClick={ () => setDir(rotateDir(state.door.dir, 1)) }
|
||||
/>
|
||||
<Base
|
||||
data-testid="entry-dir-next"
|
||||
pointer
|
||||
title="Ruota a destra"
|
||||
className="w-7 h-9 flex items-center justify-center text-zinc-600 hover:bg-zinc-100"
|
||||
onClick={ () => setDir(rotateDir(state.door.dir, 1)) }
|
||||
>
|
||||
<FaChevronRight size={ 12 } />
|
||||
</Base>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<ThicknessSegmented
|
||||
label="Pareti"
|
||||
value={ state.thickness.wall }
|
||||
onChange={ setWall }
|
||||
testIdPrefix="wall-thickness"
|
||||
labelKeyPrefix="navigator.roomsettings.wall_thickness"
|
||||
/>
|
||||
|
||||
<ThicknessSegmented
|
||||
label="Pavimenti"
|
||||
value={ state.thickness.floor }
|
||||
onChange={ setFloor }
|
||||
testIdPrefix="floor-thickness"
|
||||
labelKeyPrefix="navigator.roomsettings.floor_thickness"
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
type SegmentedProps = {
|
||||
label: string;
|
||||
value: ThicknessLevel;
|
||||
onChange: (next: ThicknessLevel) => void;
|
||||
testIdPrefix: string;
|
||||
labelKeyPrefix: string;
|
||||
};
|
||||
|
||||
const ThicknessSegmented: FC<SegmentedProps> = ({ label, value, onChange, testIdPrefix, labelKeyPrefix }) =>
|
||||
{
|
||||
return (
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Text bold small className="text-zinc-700">{ label }</Text>
|
||||
<Flex className="rounded border border-zinc-300 bg-white overflow-hidden">
|
||||
{ THICKNESS_LEVELS.map(t =>
|
||||
{
|
||||
const active = value === t;
|
||||
|
||||
return (
|
||||
<Base
|
||||
key={ `${ testIdPrefix }-${ t }` }
|
||||
data-testid={ `${ testIdPrefix }-${ t }` }
|
||||
pointer
|
||||
title={ LocalizeText(`${ labelKeyPrefix }.${ THICKNESS_NAMES[t] }`) }
|
||||
className={ `px-2 h-9 flex items-center justify-center text-xs ${ active ? 'bg-emerald-500 text-white font-bold' : 'text-zinc-700 hover:bg-zinc-100' } ${ t < THICKNESS_LEVELS.length - 1 ? 'border-r border-zinc-300' : '' }` }
|
||||
onClick={ () => onChange(t) }
|
||||
>
|
||||
{ LocalizeText(`${ labelKeyPrefix }.${ THICKNESS_NAMES[t] }`) }
|
||||
</Base>
|
||||
);
|
||||
}) }
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
import { LocalizeText } from '../../../api';
|
||||
import { Flex, LayoutGridItem, Text } from '../../../common';
|
||||
import { FloorAction } from '@nitrots/nitro-renderer';
|
||||
import { FloorplanEditor } from '@nitrots/nitro-renderer';
|
||||
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
|
||||
|
||||
interface FloorplanOptionsViewProps
|
||||
{
|
||||
}
|
||||
|
||||
export const FloorplanOptionsView: FC<FloorplanOptionsViewProps> = props =>
|
||||
{
|
||||
const { visualizationSettings = null, setVisualizationSettings = null, floorAction, setFloorAction } = useFloorplanEditorContext();
|
||||
const isSquareSelectMode = FloorplanEditor.instance.isSquareSelectMode;
|
||||
|
||||
const selectAction = (action: number) =>
|
||||
{
|
||||
setFloorAction(action);
|
||||
|
||||
FloorplanEditor.instance.actionSettings.currentAction = action;
|
||||
};
|
||||
|
||||
const toggleSquareSelectMode = () =>
|
||||
{
|
||||
FloorplanEditor.instance.toggleSquareSelectMode();
|
||||
// force re-render by toggling action to same value
|
||||
setFloorAction(prev => prev);
|
||||
};
|
||||
|
||||
const changeDoorDirection = () =>
|
||||
{
|
||||
setVisualizationSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
if(newValue.entryPointDir < 7)
|
||||
{
|
||||
++newValue.entryPointDir;
|
||||
}
|
||||
else
|
||||
{
|
||||
newValue.entryPointDir = 0;
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
const onWallThicknessChange = (value: number) =>
|
||||
{
|
||||
setVisualizationSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.thicknessWall = value;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
const onFloorThicknessChange = (value: number) =>
|
||||
{
|
||||
setVisualizationSettings(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.thicknessFloor = value;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={ 2 } alignItems="center">
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Text bold small>{ LocalizeText('floor.plan.editor.draw.mode') }</Text>
|
||||
<Flex gap={ 1 }>
|
||||
<LayoutGridItem itemActive={ (floorAction === FloorAction.SET) } onClick={ () => selectAction(FloorAction.SET) }>
|
||||
<i className="nitro-icon icon-set-tile" />
|
||||
</LayoutGridItem>
|
||||
<LayoutGridItem itemActive={ (floorAction === FloorAction.UNSET) } onClick={ () => selectAction(FloorAction.UNSET) }>
|
||||
<i className="nitro-icon icon-unset-tile" />
|
||||
</LayoutGridItem>
|
||||
<LayoutGridItem itemActive={ (floorAction === FloorAction.UP) } onClick={ () => selectAction(FloorAction.UP) }>
|
||||
<i className="nitro-icon icon-increase-height" />
|
||||
</LayoutGridItem>
|
||||
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOWN) } onClick={ () => selectAction(FloorAction.DOWN) }>
|
||||
<i className="nitro-icon icon-decrease-height" />
|
||||
</LayoutGridItem>
|
||||
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOOR) } onClick={ () => selectAction(FloorAction.DOOR) }>
|
||||
<i className="nitro-icon icon-set-door" />
|
||||
</LayoutGridItem>
|
||||
<LayoutGridItem onClick={ () => FloorplanEditor.instance.toggleSelectAll() }>
|
||||
<i className={ `nitro-icon ${ floorAction === FloorAction.UNSET ? 'icon-set-deselect' : 'icon-set-select' }` } />
|
||||
</LayoutGridItem>
|
||||
<LayoutGridItem itemActive={ isSquareSelectMode } onClick={ toggleSquareSelectMode }>
|
||||
<i className={ `nitro-icon ${ isSquareSelectMode ? 'icon-set-active-squaresselect' : 'icon-set-squaresselect' }` } />
|
||||
</LayoutGridItem>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Text bold small>{ LocalizeText('floor.plan.editor.enter.direction') }</Text>
|
||||
<i className={ `nitro-icon icon-door-direction-${ visualizationSettings.entryPointDir } cursor-pointer` } onClick={ changeDoorDirection } />
|
||||
</Flex>
|
||||
<Flex gap={ 1 } alignItems="center" className="ml-auto">
|
||||
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessWall } onChange={ event => onWallThicknessChange(parseInt(event.target.value)) }>
|
||||
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
|
||||
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
|
||||
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
|
||||
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
|
||||
</select>
|
||||
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessFloor } onChange={ event => onFloorThicknessChange(parseInt(event.target.value)) }>
|
||||
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
|
||||
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
|
||||
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
|
||||
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
|
||||
</select>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { FloorplanPreviewSVG } from './FloorplanPreviewSVG';
|
||||
import { initialState } from '../state/reducer';
|
||||
|
||||
describe('FloorplanPreviewSVG', () =>
|
||||
{
|
||||
it('renders nothing for empty tilemap', () =>
|
||||
{
|
||||
const { container } = render(<FloorplanPreviewSVG state={ initialState } />);
|
||||
expect(container.querySelector('polygon')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a floor polygon per non-blocked tile', () =>
|
||||
{
|
||||
const state = {
|
||||
...initialState,
|
||||
tiles: [
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }]
|
||||
]
|
||||
};
|
||||
const { container } = render(<FloorplanPreviewSVG state={ state } />);
|
||||
expect(container.querySelectorAll('[data-role="floor"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders wall polygons when wallHeight > 0', () =>
|
||||
{
|
||||
const state = {
|
||||
...initialState,
|
||||
wallHeight: 4,
|
||||
tiles: [
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }],
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }]
|
||||
]
|
||||
};
|
||||
const { container } = render(<FloorplanPreviewSVG state={ state } />);
|
||||
expect(container.querySelectorAll('[data-role="wall"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does NOT render walls when wallHeight is 0 or negative', () =>
|
||||
{
|
||||
const state = {
|
||||
...initialState,
|
||||
wallHeight: 0,
|
||||
tiles: [[{ h: 0, blocked: false }]]
|
||||
};
|
||||
const { container } = render(<FloorplanPreviewSVG state={ state } />);
|
||||
expect(container.querySelectorAll('[data-role="wall"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { FC, useMemo } from 'react';
|
||||
import { FloorplanState, Tile } from '../state/types';
|
||||
import { tileFill } from '../state/selectors';
|
||||
import { TILE_SIZE } from '../state/constants';
|
||||
import { tileToScreen } from '../hooks/usePointerToTile';
|
||||
|
||||
const WALL_FILL_LEFT = '#8a8a8a';
|
||||
const WALL_FILL_BACK = '#cfcfcf';
|
||||
|
||||
const diamond = (row: number, col: number, h: number): string =>
|
||||
{
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const cy = cyBase - h * (TILE_SIZE / 8);
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
return `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
|
||||
};
|
||||
|
||||
const wallBack = (row: number, col: number, wallH: number): string =>
|
||||
{
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
const top = cyBase - quarter;
|
||||
const wallPx = wallH * TILE_SIZE;
|
||||
return `${ cx },${ top } ${ cx + half },${ top + quarter } ${ cx + half },${ top + quarter - wallPx } ${ cx },${ top - wallPx }`;
|
||||
};
|
||||
|
||||
const wallLeft = (row: number, col: number, wallH: number): string =>
|
||||
{
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
const top = cyBase - quarter;
|
||||
const wallPx = wallH * TILE_SIZE;
|
||||
return `${ cx - half },${ top + quarter } ${ cx },${ top } ${ cx },${ top - wallPx } ${ cx - half },${ top + quarter - wallPx }`;
|
||||
};
|
||||
|
||||
const isPlaced = (t: Tile | undefined): boolean => !!t && !t.blocked;
|
||||
|
||||
export const FloorplanPreviewSVG: FC<{ state: FloorplanState }> = ({ state }) =>
|
||||
{
|
||||
const elements = useMemo(() =>
|
||||
{
|
||||
const out: React.ReactNode[] = [];
|
||||
for(let r = 0; r < state.tiles.length; r++)
|
||||
{
|
||||
const row = state.tiles[r];
|
||||
for(let c = 0; c < row.length; c++)
|
||||
{
|
||||
const t = row[c];
|
||||
if(!isPlaced(t)) continue;
|
||||
out.push(<polygon key={ `f-${ r }-${ c }` } data-role="floor" points={ diamond(r, c, t.h) } fill={ tileFill(t) } stroke="#222" strokeWidth={ 0.4 } />);
|
||||
if(state.wallHeight > 0)
|
||||
{
|
||||
const above = state.tiles[r - 1]?.[c];
|
||||
const left = state.tiles[r]?.[c - 1];
|
||||
if(!isPlaced(above))
|
||||
{
|
||||
out.push(<polygon key={ `wb-${ r }-${ c }` } data-role="wall" points={ wallBack(r, c, state.wallHeight) } fill={ WALL_FILL_BACK } stroke="#333" strokeWidth={ 0.4 } />);
|
||||
}
|
||||
if(!isPlaced(left))
|
||||
{
|
||||
out.push(<polygon key={ `wl-${ r }-${ c }` } data-role="wall" points={ wallLeft(r, c, state.wallHeight) } fill={ WALL_FILL_LEFT } stroke="#333" strokeWidth={ 0.4 } />);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [ state.tiles, state.wallHeight ]);
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 2048 1024" className="w-full h-full bg-black">
|
||||
{ elements }
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,328 +0,0 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { COLORMAP, HEIGHT_SCHEME, FloorplanEditor } from '@nitrots/nitro-renderer';
|
||||
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
|
||||
|
||||
const colormap = COLORMAP as Record<string, string>;
|
||||
|
||||
const PREVIEW_TILE_W = 16;
|
||||
const PREVIEW_TILE_H = 8;
|
||||
const PREVIEW_BLOCK_H = 5;
|
||||
const WALL_HEIGHT_PX = 40;
|
||||
const WALL_COLOR = '#6B7B5E';
|
||||
const WALL_SIDE_COLOR = '#5A6A4F';
|
||||
const WALL_TOP_COLOR = '#7D8E6F';
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number]
|
||||
{
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
return [ r, g, b ];
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string
|
||||
{
|
||||
return `#${ ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) }`;
|
||||
}
|
||||
|
||||
function darken(hex: string, factor: number): string
|
||||
{
|
||||
const [ r, g, b ] = hexToRgb(hex);
|
||||
|
||||
return rgbToHex(
|
||||
Math.floor(r * factor),
|
||||
Math.floor(g * factor),
|
||||
Math.floor(b * factor)
|
||||
);
|
||||
}
|
||||
|
||||
function getTilemapBounds(tilemap: any[][]): { minX: number; minY: number; maxX: number; maxY: number }
|
||||
{
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
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;
|
||||
|
||||
if(x < minX) minX = x;
|
||||
if(x > maxX) maxX = x;
|
||||
if(y < minY) minY = y;
|
||||
if(y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
|
||||
if(minX === Infinity) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function renderPreview(canvas: HTMLCanvasElement, wallHeight: number): void
|
||||
{
|
||||
const ctx = canvas.getContext('2d');
|
||||
const tilemap = FloorplanEditor.instance.tilemap;
|
||||
|
||||
if(!ctx || !tilemap || tilemap.length === 0)
|
||||
{
|
||||
if(ctx)
|
||||
{
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = getTilemapBounds(tilemap);
|
||||
const tilesW = bounds.maxX - bounds.minX + 1;
|
||||
const tilesH = bounds.maxY - bounds.minY + 1;
|
||||
|
||||
// find max height for offset calculation
|
||||
let maxTileHeight = 0;
|
||||
|
||||
for(let y = bounds.minY; y <= bounds.maxY; y++)
|
||||
{
|
||||
for(let x = bounds.minX; x <= bounds.maxX; x++)
|
||||
{
|
||||
if(!tilemap[y] || !tilemap[y][x] || tilemap[y][x].height === 'x') continue;
|
||||
|
||||
const hi = HEIGHT_SCHEME.indexOf(tilemap[y][x].height) - 1;
|
||||
|
||||
if(hi > maxTileHeight) maxTileHeight = hi;
|
||||
}
|
||||
}
|
||||
|
||||
// calculate isometric bounds
|
||||
const isoW = (tilesW + tilesH) * PREVIEW_TILE_W;
|
||||
const isoH = (tilesW + tilesH) * PREVIEW_TILE_H + maxTileHeight * PREVIEW_BLOCK_H + WALL_HEIGHT_PX;
|
||||
|
||||
// scale to fit canvas
|
||||
const scaleX = (canvas.width - 20) / isoW;
|
||||
const scaleY = (canvas.height - 20) / isoH;
|
||||
const scale = Math.min(scaleX, scaleY, 3);
|
||||
|
||||
const offsetX = (canvas.width - isoW * scale) / 2;
|
||||
const offsetY = (canvas.height - isoH * scale) / 2 + WALL_HEIGHT_PX * scale * 0.5;
|
||||
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(offsetX, offsetY);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
const tw = PREVIEW_TILE_W;
|
||||
const th = PREVIEW_TILE_H;
|
||||
|
||||
function isoX(gx: number, gy: number): number
|
||||
{
|
||||
return (gx - bounds.minX - gy + bounds.minY) * tw + (tilesH - 1) * tw;
|
||||
}
|
||||
|
||||
function isoY(gx: number, gy: number): number
|
||||
{
|
||||
return (gx - bounds.minX + gy - bounds.minY) * th;
|
||||
}
|
||||
|
||||
function hasActiveTile(gx: number, gy: number): boolean
|
||||
{
|
||||
return tilemap[gy] && tilemap[gy][gx] && tilemap[gy][gx].height !== 'x';
|
||||
}
|
||||
|
||||
function getTileHeight(gx: number, gy: number): number
|
||||
{
|
||||
if(!hasActiveTile(gx, gy)) return 0;
|
||||
|
||||
return Math.max(0, HEIGHT_SCHEME.indexOf(tilemap[gy][gx].height) - 1);
|
||||
}
|
||||
|
||||
// draw walls on north and west edges
|
||||
const wallH = wallHeight > 0 ? wallHeight * PREVIEW_BLOCK_H + WALL_HEIGHT_PX * 0.3 : WALL_HEIGHT_PX * 0.6;
|
||||
|
||||
for(let y = bounds.minY; y <= bounds.maxY; y++)
|
||||
{
|
||||
for(let x = bounds.minX; x <= bounds.maxX; x++)
|
||||
{
|
||||
if(!hasActiveTile(x, y)) continue;
|
||||
|
||||
const tileH = getTileHeight(x, y) * PREVIEW_BLOCK_H;
|
||||
const cx = isoX(x, y);
|
||||
const cy = isoY(x, y) - tileH;
|
||||
|
||||
// west wall (no tile to the left)
|
||||
if(!hasActiveTile(x - 1, y))
|
||||
{
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy + th);
|
||||
ctx.lineTo(cx, cy + th - wallH);
|
||||
ctx.lineTo(cx + tw, cy - wallH);
|
||||
ctx.lineTo(cx + tw, cy);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = WALL_SIDE_COLOR;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#4A5A3F';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// north wall (no tile above)
|
||||
if(!hasActiveTile(x, y - 1))
|
||||
{
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + tw, cy);
|
||||
ctx.lineTo(cx + tw, cy - wallH);
|
||||
ctx.lineTo(cx + tw * 2, cy + th - wallH);
|
||||
ctx.lineTo(cx + tw * 2, cy + th);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = WALL_COLOR;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#4A5A3F';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// wall top cap - corner
|
||||
if(!hasActiveTile(x - 1, y) && !hasActiveTile(x, y - 1))
|
||||
{
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + tw, cy - wallH);
|
||||
ctx.lineTo(cx + tw + tw * 0.3, cy - wallH - th * 0.3);
|
||||
ctx.lineTo(cx + tw, cy - wallH - th * 0.6);
|
||||
ctx.lineTo(cx + tw - tw * 0.3, cy - wallH - th * 0.3);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = WALL_TOP_COLOR;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// draw tiles back-to-front
|
||||
for(let y = bounds.minY; y <= bounds.maxY; y++)
|
||||
{
|
||||
for(let x = bounds.minX; x <= bounds.maxX; x++)
|
||||
{
|
||||
if(!hasActiveTile(x, y)) continue;
|
||||
|
||||
const tile = tilemap[y][x];
|
||||
const heightIndex = HEIGHT_SCHEME.indexOf(tile.height) - 1;
|
||||
const tileH = Math.max(0, heightIndex) * PREVIEW_BLOCK_H;
|
||||
|
||||
const cx = isoX(x, y);
|
||||
const cy = isoY(x, y) - tileH;
|
||||
|
||||
const heightChar = tile.height;
|
||||
const baseColor = colormap[heightChar] || 'aaaaaa';
|
||||
const topColor = `#${ baseColor }`;
|
||||
const leftColor = darken(baseColor, 0.65);
|
||||
const rightColor = darken(baseColor, 0.80);
|
||||
|
||||
// draw side faces if tile has height
|
||||
const blockH = Math.max(0, heightIndex) * PREVIEW_BLOCK_H;
|
||||
|
||||
// left face (visible when no neighbor to south or neighbor is shorter)
|
||||
const southH = getTileHeight(x, y + 1);
|
||||
const leftExpose = hasActiveTile(x, y + 1) ? Math.max(0, heightIndex - southH) * PREVIEW_BLOCK_H : blockH + PREVIEW_BLOCK_H;
|
||||
|
||||
if(leftExpose > 0)
|
||||
{
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy + th);
|
||||
ctx.lineTo(cx + tw, cy + th * 2);
|
||||
ctx.lineTo(cx + tw, cy + th * 2 + leftExpose);
|
||||
ctx.lineTo(cx, cy + th + leftExpose);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = leftColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// right face
|
||||
const eastH = getTileHeight(x + 1, y);
|
||||
const rightExpose = hasActiveTile(x + 1, y) ? Math.max(0, heightIndex - eastH) * PREVIEW_BLOCK_H : blockH + PREVIEW_BLOCK_H;
|
||||
|
||||
if(rightExpose > 0)
|
||||
{
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + tw * 2, cy + th);
|
||||
ctx.lineTo(cx + tw, cy + th * 2);
|
||||
ctx.lineTo(cx + tw, cy + th * 2 + rightExpose);
|
||||
ctx.lineTo(cx + tw * 2, cy + th + rightExpose);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = rightColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// top face
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + tw, cy);
|
||||
ctx.lineTo(cx + tw * 2, cy + th);
|
||||
ctx.lineTo(cx + tw, cy + th * 2);
|
||||
ctx.lineTo(cx, cy + th);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = topColor;
|
||||
ctx.fill();
|
||||
|
||||
// door indicator
|
||||
const door = FloorplanEditor.instance.doorLocation;
|
||||
|
||||
if(door.x === x && door.y === y)
|
||||
{
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export const FloorplanPreviewView: FC<{}> = () =>
|
||||
{
|
||||
const { tilemapVersion, visualizationSettings } = useFloorplanEditorContext();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!canvasRef.current) return;
|
||||
|
||||
if(rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
|
||||
rafRef.current = requestAnimationFrame(() =>
|
||||
{
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if(!canvas) return;
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
|
||||
if(parent)
|
||||
{
|
||||
canvas.width = parent.clientWidth;
|
||||
canvas.height = parent.clientHeight;
|
||||
}
|
||||
|
||||
renderPreview(canvas, visualizationSettings?.wallHeight ?? 0);
|
||||
});
|
||||
|
||||
return () =>
|
||||
{
|
||||
if(rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [ tilemapVersion, visualizationSettings?.wallHeight ]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative rounded overflow-hidden border-2 border-muted" style={ { minHeight: 200, backgroundColor: '#1a1a1a' } }>
|
||||
<canvas
|
||||
ref={ canvasRef }
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { GetRoomEngine, RoomPreviewer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { LayoutRoomPreviewerView } from '../../../common/layout/LayoutRoomPreviewerView';
|
||||
import { serializeTilemap } from '../state/encoding';
|
||||
import { FloorplanState } from '../state/types';
|
||||
|
||||
type Props = {
|
||||
state: FloorplanState;
|
||||
/** Outer container height; the previewer fills the parent width. */
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Textured isometric room preview driven by the renderer's
|
||||
* RoomPreviewer (the same engine the catalog uses for furniture
|
||||
* thumbnails). Whenever the editor's tilemap / wallHeight changes,
|
||||
* `RoomPreviewer.updatePreviewModel` re-renders the floor with
|
||||
* actual sand/plaster textures — far closer to what the room will
|
||||
* look like in-game than the previous SVG-on-black preview.
|
||||
*
|
||||
* IMPORTANT — construction lives INSIDE the lifecycle effect, not
|
||||
* in a lazy `useState` initializer. RoomPreviewer.dispose() nulls
|
||||
* out internal fields (`_planeParser`, `_backgroundSprite`, …), so
|
||||
* once we've disposed an instance any subsequent
|
||||
* `updatePreviewModel` call on it crashes with "this._planeParser
|
||||
* is null". React 19 StrictMode runs each effect setup → cleanup →
|
||||
* setup again on first mount in dev: a lazy useState would hand
|
||||
* the same disposed instance to the second setup. By creating the
|
||||
* previewer inside the effect and writing it to state, the
|
||||
* StrictMode re-run gets a fresh instance — matching the pattern
|
||||
* useCatalog already uses for the same renderer object.
|
||||
*/
|
||||
export const FloorplanRoomPreview: FC<Props> = ({ state, height = 320 }) =>
|
||||
{
|
||||
const [ previewer, setPreviewer ] = useState<RoomPreviewer | null>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const instance = new RoomPreviewer(GetRoomEngine(), ++RoomPreviewer.PREVIEW_COUNTER);
|
||||
|
||||
setPreviewer(instance);
|
||||
|
||||
return () =>
|
||||
{
|
||||
instance.dispose();
|
||||
setPreviewer(prev => (prev === instance ? null : prev));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tilemap = useMemo(() => serializeTilemap(state.tiles), [ state.tiles ]);
|
||||
|
||||
// Push the current editor model into the previewer whenever it
|
||||
// changes. updatePreviewModel re-runs the same plane-parser +
|
||||
// ObjectRoomMapUpdateMessage pipeline as the in-room
|
||||
// applyFloorModelLocally, so the textured preview matches the
|
||||
// live in-room preview pixel-for-pixel.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!previewer) return;
|
||||
if(!tilemap) return;
|
||||
// server-space wall height: editor stores 1+, wire is 0-based
|
||||
previewer.updatePreviewModel(tilemap, Math.max(0, state.wallHeight - 1), true);
|
||||
}, [ previewer, tilemap, state.wallHeight ]);
|
||||
|
||||
if(!previewer) return <div className="w-full" style={ { height } } />;
|
||||
|
||||
return <LayoutRoomPreviewerView roomPreviewer={ previewer } height={ height } />;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { FloorplanTile } from './FloorplanTile';
|
||||
|
||||
const svg = (children: React.ReactNode) => <svg>{ children }</svg>;
|
||||
|
||||
describe('FloorplanTile', () =>
|
||||
{
|
||||
it('renders nothing for blocked tile by default', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 0, blocked: true } } selected={ false } isDoor={ false } />));
|
||||
expect(container.querySelector('polygon')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a polygon for non-blocked tile', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 3, blocked: false } } selected={ false } isDoor={ false } />));
|
||||
const poly = container.querySelector('polygon');
|
||||
expect(poly).toBeTruthy();
|
||||
expect(poly?.getAttribute('fill')).toMatch(/^#/);
|
||||
expect(poly?.getAttribute('points')?.split(' ')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders a door marker when isDoor=true', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 1 } col={ 1 } tile={ { h: 0, blocked: false } } selected={ false } isDoor={ true } />));
|
||||
expect(container.querySelector('[data-testid="door-marker"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies selection ring when selected', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 0, blocked: false } } selected={ true } isDoor={ false } />));
|
||||
expect(container.querySelector('[data-testid="selection-ring"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FC, memo } from 'react';
|
||||
import { Tile } from '../state/types';
|
||||
import { tileFill } from '../state/selectors';
|
||||
import { TILE_SIZE } from '../state/constants';
|
||||
import { tileToScreen } from '../hooks/usePointerToTile';
|
||||
|
||||
type Props = {
|
||||
row: number;
|
||||
col: number;
|
||||
tile: Tile;
|
||||
selected: boolean;
|
||||
isDoor: boolean;
|
||||
};
|
||||
|
||||
const diamondPoints = (row: number, col: number, h: number): string =>
|
||||
{
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const cy = cyBase - h * (TILE_SIZE / 8);
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
// Diamond corners: top, right, bottom, left
|
||||
return `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
|
||||
};
|
||||
|
||||
const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor }) =>
|
||||
{
|
||||
if(tile.blocked) return null;
|
||||
const points = diamondPoints(row, col, tile.h);
|
||||
const fill = tileFill(tile);
|
||||
return (
|
||||
<g>
|
||||
<polygon points={ points } fill={ fill } stroke="#222" strokeWidth={ 0.5 } />
|
||||
{ selected && (
|
||||
<polygon
|
||||
data-testid="selection-ring"
|
||||
points={ points }
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth={ 2 }
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
) }
|
||||
{ isDoor && (
|
||||
<polygon
|
||||
data-testid="door-marker"
|
||||
points={ points }
|
||||
fill="rgba(255,255,255,0.85)"
|
||||
stroke="#000"
|
||||
strokeWidth={ 1 }
|
||||
/>
|
||||
) }
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export const FloorplanTile = memo(FloorplanTileImpl);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { fireEvent, render, cleanup } from '@testing-library/react';
|
||||
import { FloorplanToolbar } from './FloorplanToolbar';
|
||||
import { initialState } from '../state/reducer';
|
||||
|
||||
describe('FloorplanToolbar', () =>
|
||||
{
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('clicking SET button dispatches BRUSH_SET action=SET', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('tool-set'));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'BRUSH_SET', action: 'SET' });
|
||||
});
|
||||
|
||||
it('all 5 brush actions are reachable', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('tool-unset'));
|
||||
fireEvent.click(getByTestId('tool-up'));
|
||||
fireEvent.click(getByTestId('tool-down'));
|
||||
fireEvent.click(getByTestId('tool-door'));
|
||||
const types = dispatch.mock.calls.map(c => c[0].action);
|
||||
expect(types).toEqual([ 'UNSET', 'UP', 'DOWN', 'DOOR' ]);
|
||||
});
|
||||
|
||||
it('select-all and square-select dispatch their actions', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('tool-select-all'));
|
||||
fireEvent.click(getByTestId('tool-square-select'));
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SELECT_ALL' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
});
|
||||
|
||||
it('marks active brush button with data-active', () =>
|
||||
{
|
||||
const state = { ...initialState, brush: { h: 0, action: 'UP' as const } };
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ state } dispatch={ () => {} } />);
|
||||
expect(getByTestId('tool-up').getAttribute('data-active')).toBe('true');
|
||||
expect(getByTestId('tool-set').getAttribute('data-active')).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Dispatch, FC } from 'react';
|
||||
import { FaRedo, FaUndo } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../api';
|
||||
import { Base, Flex, Text } from '../../../common';
|
||||
import { FloorplanAction, FloorActionMode, FloorplanState } from '../state/types';
|
||||
|
||||
type Props = {
|
||||
state: FloorplanState;
|
||||
dispatch: Dispatch<FloorplanAction>;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
};
|
||||
|
||||
const BRUSH_BUTTONS: { id: string; mode: FloorActionMode; iconClass: string }[] = [
|
||||
{ id: 'tool-set', mode: 'SET', iconClass: 'icon-set-tile' },
|
||||
{ id: 'tool-unset', mode: 'UNSET', iconClass: 'icon-unset-tile' },
|
||||
{ id: 'tool-up', mode: 'UP', iconClass: 'icon-increase-height' },
|
||||
{ id: 'tool-down', mode: 'DOWN', iconClass: 'icon-decrease-height' },
|
||||
{ id: 'tool-door', mode: 'DOOR', iconClass: 'icon-set-door' }
|
||||
];
|
||||
|
||||
export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo, onUndo, onRedo }) =>
|
||||
{
|
||||
return (
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Text bold small>{ LocalizeText('floor.plan.editor.draw.mode') }</Text>
|
||||
{ BRUSH_BUTTONS.map(b => (
|
||||
<Base
|
||||
key={ b.id }
|
||||
pointer
|
||||
data-testid={ b.id }
|
||||
data-active={ state.brush.action === b.mode ? 'true' : 'false' }
|
||||
className={ `nitro-icon ${ b.iconClass } ${ state.brush.action === b.mode ? 'border border-primary' : '' }` }
|
||||
onClick={ () => dispatch({ type: 'BRUSH_SET', action: b.mode }) }
|
||||
/>
|
||||
)) }
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-select-all"
|
||||
className={ `nitro-icon ${ state.selection.size > 0 ? 'icon-set-deselect' : 'icon-set-select' }` }
|
||||
onClick={ () => dispatch({ type: 'SELECT_ALL' }) }
|
||||
/>
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-square-select"
|
||||
className={ `nitro-icon icon-set-squaresselect ${ state.squareSelect ? 'border border-primary' : '' }` }
|
||||
onClick={ () => dispatch({ type: 'SQUARE_SELECT_TOGGLE' }) }
|
||||
/>
|
||||
{ (onUndo || onRedo) && (
|
||||
<Flex gap={ 1 } alignItems="center" className="ml-2 pl-2 border-l border-zinc-300">
|
||||
<Base
|
||||
pointer={ Boolean(canUndo) }
|
||||
data-testid="tool-undo"
|
||||
title="Annulla (Ctrl+Z)"
|
||||
className={ `w-7 h-7 flex items-center justify-center rounded border ${ canUndo ? 'border-zinc-300 bg-white hover:bg-zinc-50 text-zinc-700' : 'border-zinc-200 bg-zinc-100 text-zinc-300 cursor-not-allowed' }` }
|
||||
onClick={ canUndo && onUndo ? onUndo : undefined }
|
||||
>
|
||||
<FaUndo size={ 12 } />
|
||||
</Base>
|
||||
<Base
|
||||
pointer={ Boolean(canRedo) }
|
||||
data-testid="tool-redo"
|
||||
title="Ripeti (Ctrl+Shift+Z)"
|
||||
className={ `w-7 h-7 flex items-center justify-center rounded border ${ canRedo ? 'border-zinc-300 bg-white hover:bg-zinc-50 text-zinc-700' : 'border-zinc-200 bg-zinc-100 text-zinc-300 cursor-not-allowed' }` }
|
||||
onClick={ canRedo && onRedo ? onRedo : undefined }
|
||||
>
|
||||
<FaRedo size={ 12 } />
|
||||
</Base>
|
||||
</Flex>
|
||||
) }
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
+123
-1
@@ -233,6 +233,94 @@ export class UserProfileComposer extends StubClass {}
|
||||
// `ChooserSelectionFilter` is used as a string enum in some call sites.
|
||||
export const ChooserSelectionFilter = makeEnumProxy('ChooserSelectionFilter');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floor plan constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FloorAction = makeEnumProxy('FloorAction');
|
||||
|
||||
export const TILE_SIZE = 32;
|
||||
|
||||
export const HEIGHT_SCHEME = 'x0123456789abcdefghijklmnopq';
|
||||
|
||||
export const MAX_NUM_TILE_PER_AXIS = 64;
|
||||
|
||||
export const COLORMAP: object = {
|
||||
'x': '101010',
|
||||
'0': '0065ff', '1': '0091ff', '2': '00bcff', '3': '00e8ff',
|
||||
'4': '00ffea', '5': '00ffbf', '6': '00ff93', '7': '00ff68',
|
||||
'8': '00ff3d', '9': '19ff00',
|
||||
'a': '44ff00', 'b': '70ff00', 'c': '9bff00', 'd': 'f2ff00',
|
||||
'e': 'ffe000', 'f': 'ffb500', 'g': 'ff8900', 'h': 'ff5e00',
|
||||
'i': 'ff3200', 'j': 'ff0700', 'k': 'ff0023', 'l': 'ff007a',
|
||||
'm': 'ff00a5', 'n': 'ff00d1', 'o': 'ff00fc',
|
||||
'p': 'd600ff', 'q': 'aa00ff'
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floor plan editor — composer stubs and event classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Composer stubs for floor-plan-editor message events
|
||||
export class GetRoomEntryTileMessageComposer extends StubClass {}
|
||||
export class GetOccupiedTilesMessageComposer extends StubClass {}
|
||||
export class UpdateFloorPropertiesMessageComposer extends StubClass
|
||||
{
|
||||
public tilemap: string;
|
||||
public doorX: number;
|
||||
public doorY: number;
|
||||
public dir: number;
|
||||
public thicknessWall: number;
|
||||
public thicknessFloor: number;
|
||||
public wallHeight: number;
|
||||
constructor(tilemap: string, doorX: number, doorY: number, dir: number, thicknessWall: number, thicknessFloor: number, wallHeight: number)
|
||||
{
|
||||
super();
|
||||
this.tilemap = tilemap;
|
||||
this.doorX = doorX;
|
||||
this.doorY = doorY;
|
||||
this.dir = dir;
|
||||
this.thicknessWall = thicknessWall;
|
||||
this.thicknessFloor = thicknessFloor;
|
||||
this.wallHeight = wallHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Event class stubs for useMessageEvent registration
|
||||
export class FloorHeightMapEvent extends StubClass {}
|
||||
export class RoomVisualizationSettingsEvent extends StubClass {}
|
||||
export class RoomEntryTileMessageEvent extends StubClass {}
|
||||
export class RoomOccupiedTilesMessageEvent extends StubClass {}
|
||||
export const RoomEngineEvent = makeEnumProxy('RoomEngineEvent');
|
||||
|
||||
// Link tracker stubs
|
||||
export type ILinkEventTracker = { linkReceived: (url: string) => void; eventUrlPrefix: string };
|
||||
export const AddLinkEventTracker = vi.fn();
|
||||
export const RemoveLinkEventTracker = vi.fn();
|
||||
|
||||
// Thickness conversion helpers — mirror the renderer's real mapping
|
||||
export const convertNumbersForSaving = (v: number): number =>
|
||||
{
|
||||
switch(v)
|
||||
{
|
||||
case 0: return -2;
|
||||
case 1: return -1;
|
||||
case 3: return 1;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const convertSettingToNumber = (v: number): number =>
|
||||
{
|
||||
switch(v)
|
||||
{
|
||||
case 0.25: return 0;
|
||||
case 0.5: return 1;
|
||||
case 2: return 3;
|
||||
default: return 2;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton getters
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -267,10 +355,44 @@ export const GetCommunication = vi.fn(stubManager);
|
||||
export const GetConfiguration = vi.fn(stubManager);
|
||||
export const GetLocalizationManager = vi.fn(stubManager);
|
||||
export const GetRoomEngine = vi.fn(stubManager);
|
||||
export const GetRoomMessageHandler = vi.fn(stubManager);
|
||||
export const GetRoomSessionManager = vi.fn(stubManager);
|
||||
|
||||
// RoomPreviewer — only the bits the editor's FloorplanRoomPreview
|
||||
// component touches. PREVIEW_COUNTER is a static field that the
|
||||
// real renderer increments to allocate unique preview-room IDs;
|
||||
// keeping it as a mutable static lets the editor mount/unmount
|
||||
// repeatedly across tests without colliding.
|
||||
export class RoomPreviewer
|
||||
{
|
||||
static PREVIEW_COUNTER = 0;
|
||||
|
||||
constructor(public readonly _engine: unknown, public readonly _id: number) {}
|
||||
|
||||
public updatePreviewModel(_model: string, _wallHeight: number, _scale?: boolean): void {}
|
||||
public modifyRoomCanvas(_w: number, _h: number): void {}
|
||||
public getRoomCanvas(_w: number, _h: number): unknown { return null; }
|
||||
public getRenderingCanvas(): unknown { return null; }
|
||||
public updatePreviewRoomView(): void {}
|
||||
public changeRoomObjectDirection(): void {}
|
||||
public changeRoomObjectState(): void {}
|
||||
public dispose(): void {}
|
||||
}
|
||||
export const GetSessionDataManager = vi.fn(stubManager);
|
||||
export const GetTickerTime = vi.fn(() => 0);
|
||||
export const TextureUtils = stubManager();
|
||||
export const GetTicker = vi.fn(stubManager);
|
||||
export const GetRenderer = vi.fn(stubManager);
|
||||
export class NitroTicker {}
|
||||
// TextureUtils — a real-enough stub of the createRenderTexture
|
||||
// roundtrip. Tests that mount LayoutRoomPreviewerView allocate a
|
||||
// texture on mount and destroy it on unmount; without a real
|
||||
// `.destroy` method the unmount cleanup throws.
|
||||
export const TextureUtils = {
|
||||
createRenderTexture: (_w: number, _h: number) => ({
|
||||
destroy: (_options?: unknown) => undefined
|
||||
}),
|
||||
generateImage: () => null
|
||||
};
|
||||
export const NitroVersion = stubManager();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1 +1,34 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Set up a container for React portals (used by NitroCardView's DraggableWindow)
|
||||
const draggableWindowsContainer = document.createElement('div');
|
||||
draggableWindowsContainer.id = 'draggable-windows-container';
|
||||
document.body.appendChild(draggableWindowsContainer);
|
||||
|
||||
// jsdom doesn't ship ResizeObserver, but LayoutRoomPreviewerView (and
|
||||
// any component that resizes a canvas to its container) constructs
|
||||
// one at mount. A no-op stub is enough — the tests never assert
|
||||
// resize-driven behavior, they just need the constructor to exist.
|
||||
if(typeof globalThis.ResizeObserver === 'undefined')
|
||||
{
|
||||
class ResizeObserverStub
|
||||
{
|
||||
constructor(_callback: unknown)
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
public observe(): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
public unobserve(): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
public disconnect(): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver = ResizeObserverStub;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user