mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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,285 @@
|
||||
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 [ panMode, setPanMode ] = useState(false);
|
||||
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 });
|
||||
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;
|
||||
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' });
|
||||
});
|
||||
|
||||
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);
|
||||
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 }
|
||||
panMode={ panMode }
|
||||
setPanMode={ setPanMode }
|
||||
/>
|
||||
<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 } panMode={ panMode } />
|
||||
</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 } tiles)
|
||||
</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="Local in-room preview while drawing (does not save to 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,148 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const pastRef = useRef<FloorplanState[]>([]);
|
||||
const futureRef = useRef<FloorplanState[]>([]);
|
||||
const [ canUndo, setCanUndo ] = useState(false);
|
||||
const [ canRedo, setCanRedo ] = useState(false);
|
||||
const stateRef = useRef<FloorplanState>(state);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) =>
|
||||
{
|
||||
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 });
|
||||
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 ]);
|
||||
};
|
||||
|
||||
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,40 @@
|
||||
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', () =>
|
||||
{
|
||||
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,67 @@
|
||||
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 =>
|
||||
{
|
||||
void viewBox;
|
||||
|
||||
const fromClient = useCallback((clientX: number, clientY: number) =>
|
||||
{
|
||||
const svg = svgRef.current;
|
||||
if(!svg) return null;
|
||||
|
||||
if(typeof document !== 'undefined' && typeof document.elementFromPoint === 'function')
|
||||
{
|
||||
const hit = document.elementFromPoint(clientX, clientY) as SVGElement | null;
|
||||
if(hit)
|
||||
{
|
||||
const r = hit.getAttribute('data-row');
|
||||
const c = hit.getAttribute('data-col');
|
||||
if(r !== null && c !== null)
|
||||
{
|
||||
const row = parseInt(r, 10);
|
||||
const col = parseInt(c, 10);
|
||||
if(Number.isFinite(row) && Number.isFinite(col)) return { row, col };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ctm = svg.getScreenCTM();
|
||||
if(!ctm) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = clientX;
|
||||
pt.y = clientY;
|
||||
const local = pt.matrixTransform(ctm.inverse());
|
||||
|
||||
const [ row, col ] = screenToTile(local.x, local.y);
|
||||
return { row: Math.round(row), col: Math.round(col) };
|
||||
}, [ svgRef ]);
|
||||
|
||||
return useMemo(() => ({ fromClient }), [ fromClient ]);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('square-select drag dispatches SELECT_RECT (down + move) then APPLY_BRUSH_TO_SELECTION + SQUARE_SELECT_TOGGLE on release', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
let projTile: { row: number; col: number } = { row: 2, col: 3 };
|
||||
const projection = { fromClient: () => projTile };
|
||||
const state: FloorplanState = { ...withBrush('SET'), squareSelect: true };
|
||||
const { result } = renderHook(() => useTool(state, dispatch as React.Dispatch<FloorplanAction>, projection));
|
||||
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SELECT_RECT', from: [ 2, 3 ], to: [ 2, 3 ] });
|
||||
|
||||
projTile = { row: 5, col: 7 };
|
||||
dispatch.mockClear();
|
||||
act(() => result.current.onPointerMove({ clientX: 10, clientY: 10, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SELECT_RECT', from: [ 2, 3 ], to: [ 5, 7 ] });
|
||||
expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'PAINT_TILE' }));
|
||||
|
||||
dispatch.mockClear();
|
||||
act(() => result.current.onPointerUp({ clientX: 10, clientY: 10, pointerId: 1, currentTarget: { releasePointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
});
|
||||
|
||||
it('square-select pointer up without a prior down is a no-op', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const projection = { fromClient: () => ({ row: 0, col: 0 }) };
|
||||
const state: FloorplanState = { ...withBrush('SET'), squareSelect: true };
|
||||
const { result } = renderHook(() => useTool(state, dispatch as React.Dispatch<FloorplanAction>, projection));
|
||||
act(() => result.current.onPointerUp({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { releasePointerCapture: () => {} } } as never));
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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 squareStartRef = useRef<{ row: number; col: number } | 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 {}
|
||||
|
||||
if(state.squareSelect)
|
||||
{
|
||||
const hit = projection.fromClient(e.clientX, e.clientY);
|
||||
if(!hit) return;
|
||||
squareStartRef.current = hit;
|
||||
dispatch({ type: 'SELECT_RECT', from: [ hit.row, hit.col ], to: [ hit.row, hit.col ] });
|
||||
return;
|
||||
}
|
||||
|
||||
apply(e);
|
||||
}, [ apply, state.squareSelect, projection, dispatch ]);
|
||||
|
||||
const onPointerMove = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
if(!isDownRef.current) return;
|
||||
|
||||
if(state.squareSelect && squareStartRef.current)
|
||||
{
|
||||
const hit = projection.fromClient(e.clientX, e.clientY);
|
||||
if(!hit) return;
|
||||
const start = squareStartRef.current;
|
||||
dispatch({ type: 'SELECT_RECT', from: [ start.row, start.col ], to: [ hit.row, hit.col ] });
|
||||
return;
|
||||
}
|
||||
|
||||
if(state.brush.action === 'DOOR') return;
|
||||
apply(e);
|
||||
}, [ apply, state.brush.action, state.squareSelect, projection, dispatch ]);
|
||||
|
||||
const onPointerUp = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
isDownRef.current = false;
|
||||
lastTileRef.current = null;
|
||||
try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch {}
|
||||
|
||||
if(state.squareSelect && squareStartRef.current)
|
||||
{
|
||||
squareStartRef.current = null;
|
||||
dispatch({ type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
dispatch({ type: 'SQUARE_SELECT_TOGGLE' });
|
||||
}
|
||||
}, [ state.squareSelect, dispatch ]);
|
||||
|
||||
return { onPointerDown, onPointerMove, onPointerUp };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
|
||||
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,46 @@
|
||||
import { Tile } from './types';
|
||||
import { HEIGHT_SCHEME } from './constants';
|
||||
|
||||
const VALID_CHARS = HEIGHT_SCHEME;
|
||||
const HMIN = 0;
|
||||
const HMAX = VALID_CHARS.length - 2;
|
||||
|
||||
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,463 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { reducer, initialState } from './reducer';
|
||||
import { FloorplanState } from './types';
|
||||
import { defaultEmptyTilemap } from './selectors';
|
||||
import { MAX_NUM_TILE_PER_AXIS } from './constants';
|
||||
|
||||
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 cell in the full editor grid (MAX × MAX)', () =>
|
||||
{
|
||||
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(MAX_NUM_TILE_PER_AXIS * MAX_NUM_TILE_PER_AXIS);
|
||||
expect(next.selection.has('0,0')).toBe(true);
|
||||
expect(next.selection.has('0,1')).toBe(true);
|
||||
expect(next.selection.has(`${MAX_NUM_TILE_PER_AXIS - 1},${MAX_NUM_TILE_PER_AXIS - 1}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('SELECT_ALL + APPLY_BRUSH_TO_SELECTION SET fills empty cells inside the room shape', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: false }]
|
||||
]);
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'SET', h: 2 });
|
||||
const selected = reducer(armed, { type: 'SELECT_ALL' });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[0][1]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][1]).toEqual({ h: 2, blocked: false });
|
||||
});
|
||||
|
||||
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));
|
||||
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('SELECT_RECT includes blocked / empty cells so the SET brush can paint into them', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: false }]
|
||||
]);
|
||||
const next = reducer(start, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 1, 1 ] });
|
||||
const keys = Array.from(next.selection).sort();
|
||||
expect(keys).toEqual([ '0,0', '0,1', '1,0', '1,1' ].sort());
|
||||
});
|
||||
|
||||
it('SELECT_RECT clamps to grid bounds when the drag goes negative or past MAX', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'SELECT_RECT', from: [ -5, -5 ], to: [ 999, 999 ] });
|
||||
expect(next.selection.has('0,0')).toBe(true);
|
||||
expect(next.selection.has('63,63')).toBe(true);
|
||||
expect(next.selection.has('64,0')).toBe(false);
|
||||
expect(next.selection.has('-1,0')).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with SET fills selected tiles at brush height (including blocked ones) and clears selection', () =>
|
||||
{
|
||||
const populated = stateWith([
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }],
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }]
|
||||
]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armed = reducer(withSel, { type: 'BRUSH_SET', action: 'SET', h: 4 });
|
||||
const next = reducer(armed, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.tiles[0][1]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.tiles[1][0]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.tiles[1][1]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with UNSET erases selected tiles', () =>
|
||||
{
|
||||
const populated = stateWith([
|
||||
[{ h: 3, blocked: false }, { h: 3, blocked: false }]
|
||||
]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armed = reducer(withSel, { type: 'BRUSH_SET', action: 'UNSET' });
|
||||
const next = reducer(armed, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0].blocked).toBe(true);
|
||||
expect(next.tiles[0][1].blocked).toBe(true);
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with UP/DOWN adjusts heights', () =>
|
||||
{
|
||||
const populated = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: false }]]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armedUp = reducer(withSel, { type: 'BRUSH_SET', action: 'UP' });
|
||||
const up = reducer(armedUp, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(up.tiles[0][0].h).toBe(3);
|
||||
expect(up.tiles[0][1].h).toBe(1);
|
||||
|
||||
const withSel2 = reducer(up, { type: 'SELECT_ALL' });
|
||||
const armedDown = reducer(withSel2, { type: 'BRUSH_SET', action: 'DOWN' });
|
||||
const down = reducer(armedDown, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(down.tiles[0][0].h).toBe(2);
|
||||
expect(down.tiles[0][1].h).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION SET paints into blocked / empty cells (build-from-scratch UX)', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }]
|
||||
]);
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'SET', h: 2 });
|
||||
const selected = reducer(armed, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 1, 1 ] });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[0][1]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][1]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION SET grows the tiles array when the rect extends beyond current bounds', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }]]); // 1x1 room
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'SET', h: 1 });
|
||||
const selected = reducer(armed, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 2, 2 ] });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles.length).toBeGreaterThanOrEqual(3);
|
||||
expect(next.tiles[0].length).toBeGreaterThanOrEqual(3);
|
||||
expect(next.tiles[2][2]).toEqual({ h: 1, blocked: false });
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION UNSET still skips blocked cells (only erases painted ones)', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 3, blocked: false }, { h: 0, blocked: true }]
|
||||
]);
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'UNSET' });
|
||||
const selected = reducer(armed, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 0, 1 ] });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0].blocked).toBe(true);
|
||||
expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION on empty selection is a no-op', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 5, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with DOOR clears selection without touching tiles', () =>
|
||||
{
|
||||
const populated = stateWith([[{ h: 1, blocked: false }, { h: 2, blocked: false }]]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armed = reducer(withSel, { type: 'BRUSH_SET', action: 'DOOR' });
|
||||
const next = reducer(armed, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles).toEqual(armed.tiles);
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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,244 @@
|
||||
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 < MAX_NUM_TILE_PER_AXIS; r++)
|
||||
{
|
||||
for(let c = 0; c < MAX_NUM_TILE_PER_AXIS; c++)
|
||||
{
|
||||
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.max(0, Math.min(r0, r1));
|
||||
const rMax = Math.min(MAX_NUM_TILE_PER_AXIS - 1, Math.max(r0, r1));
|
||||
const cMin = Math.max(0, Math.min(c0, c1));
|
||||
const cMax = Math.min(MAX_NUM_TILE_PER_AXIS - 1, Math.max(c0, c1));
|
||||
const sel = new Set<`${number},${number}`>();
|
||||
for(let r = rMin; r <= rMax; r++)
|
||||
{
|
||||
for(let c = cMin; c <= cMax; c++)
|
||||
{
|
||||
sel.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
return { ...state, selection: sel };
|
||||
}
|
||||
case 'APPLY_BRUSH_TO_SELECTION':
|
||||
{
|
||||
if(state.selection.size === 0) return state;
|
||||
if(state.brush.action === 'DOOR') return { ...state, selection: new Set() };
|
||||
|
||||
let maxR = -1;
|
||||
let maxC = -1;
|
||||
for(const key of state.selection)
|
||||
{
|
||||
const [ rStr, cStr ] = key.split(',');
|
||||
const r = parseInt(rStr, 10);
|
||||
const c = parseInt(cStr, 10);
|
||||
if(r > maxR) maxR = r;
|
||||
if(c > maxC) maxC = c;
|
||||
}
|
||||
|
||||
let tiles = state.tiles;
|
||||
if(state.brush.action === 'SET' && maxR >= 0 && maxC >= 0)
|
||||
{
|
||||
tiles = ensureRect(tiles, maxR + 1, maxC + 1);
|
||||
}
|
||||
|
||||
for(const key of state.selection)
|
||||
{
|
||||
const [ rStr, cStr ] = key.split(',');
|
||||
const row = parseInt(rStr, 10);
|
||||
const col = parseInt(cStr, 10);
|
||||
const current = tiles[row]?.[col];
|
||||
if(!current) continue;
|
||||
|
||||
switch(state.brush.action)
|
||||
{
|
||||
case 'SET':
|
||||
tiles = setTile(tiles, row, col, { h: clampHeight(state.brush.h), blocked: false });
|
||||
break;
|
||||
case 'UNSET':
|
||||
if(current.blocked) continue;
|
||||
tiles = setTile(tiles, row, col, { h: current.h, blocked: true });
|
||||
break;
|
||||
case 'UP':
|
||||
if(current.blocked) continue;
|
||||
tiles = setTile(tiles, row, col, { h: clampHeight(current.h + 1), blocked: false });
|
||||
break;
|
||||
case 'DOWN':
|
||||
if(current.blocked) continue;
|
||||
tiles = setTile(tiles, row, col, { h: clampHeight(current.h - 1), blocked: false });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, tiles, selection: new Set() };
|
||||
}
|
||||
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,77 @@
|
||||
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)', () =>
|
||||
{
|
||||
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,50 @@
|
||||
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: 'APPLY_BRUSH_TO_SELECTION'; source: LocalSource }
|
||||
| { 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,58 @@
|
||||
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={ () => {} } />);
|
||||
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;
|
||||
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,308 @@
|
||||
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
|
||||
import { FaCrosshairs, FaSearchMinus, FaSearchPlus, FaSyncAlt } 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>;
|
||||
panMode?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
const FIT_PADDING = TILE_SIZE * 2;
|
||||
|
||||
const clampZoom = (z: number): number => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
|
||||
|
||||
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, panMode }) =>
|
||||
{
|
||||
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 [ flipped, setFlipped ] = useState(false);
|
||||
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
const centeredRef = useRef(false);
|
||||
|
||||
const roomBounds = useMemo(() => computeRoomBounds(state), [ state.tiles ]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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 ]);
|
||||
|
||||
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 ]);
|
||||
|
||||
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 }`);
|
||||
const south = state.tiles[r]?.[c + 1];
|
||||
const west = state.tiles[r + 1]?.[c];
|
||||
const southH = (south && !south.blocked) ? south.h : 0;
|
||||
const westH = (west && !west.blocked) ? west.h : 0;
|
||||
return <FloorplanTile key={ `${ r }-${ c }` } row={ r } col={ c } tile={ tile } selected={ selected } isDoor={ isDoor } southH={ southH } westH={ westH } />;
|
||||
});
|
||||
return <g key={ `row-${ r }` }>{ cells }</g>;
|
||||
}), [ state.tiles, state.door.x, state.door.y, state.selection ]);
|
||||
|
||||
const outOfBoundsOverlay = useMemo(() =>
|
||||
{
|
||||
if(state.selection.size === 0) return null;
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
const tilesRows = state.tiles.length;
|
||||
const tilesCols = state.tiles[0]?.length ?? 0;
|
||||
const out: JSX.Element[] = [];
|
||||
for(const key of state.selection)
|
||||
{
|
||||
const [ rStr, cStr ] = key.split(',');
|
||||
const row = parseInt(rStr, 10);
|
||||
const col = parseInt(cStr, 10);
|
||||
if(row < tilesRows && col < tilesCols) continue;
|
||||
const [ cx, cy ] = tileToScreen(row, col);
|
||||
const points = `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
|
||||
out.push(
|
||||
<polygon
|
||||
key={ `oob-${ key }` }
|
||||
points={ points }
|
||||
fill="rgba(250, 204, 21, 0.45)"
|
||||
stroke="#facc15"
|
||||
strokeWidth={ 1.5 }
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return out.length ? <g data-testid="selection-overlay">{ out }</g> : null;
|
||||
}, [ state.selection, state.tiles ]);
|
||||
|
||||
const zoomIn = useCallback(() => setZoom(z => clampZoom(z + ZOOM_STEP)), []);
|
||||
const zoomOut = useCallback(() => setZoom(z => clampZoom(z - ZOOM_STEP)), []);
|
||||
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)
|
||||
|| (e.button === 0 && Boolean(panMode));
|
||||
|
||||
const cursorClass = isPanning ? 'cursor-grabbing' : panMode ? 'cursor-grab' : '';
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<svg
|
||||
ref={ svgRef }
|
||||
viewBox={ viewBox }
|
||||
style={ flipped ? { transform: 'scaleX(-1)' } : undefined }
|
||||
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] transition-transform ${ cursorClass }` }
|
||||
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 }
|
||||
{ outOfBoundsOverlay }
|
||||
</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="Zoom out (Ctrl+wheel)"
|
||||
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="Fit room to view"
|
||||
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="Zoom in (Ctrl+wheel)"
|
||||
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="Recenter on room (keep 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>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-flip"
|
||||
data-active={ flipped ? 'true' : 'false' }
|
||||
title={ flipped ? 'Original view' : 'View from the other side' }
|
||||
className={ `w-7 h-7 flex items-center justify-center rounded transition-colors ${ flipped ? 'bg-amber-400 text-zinc-900 hover:bg-amber-500' : 'hover:bg-zinc-100' }` }
|
||||
onClick={ () => setFlipped(v => !v) }
|
||||
>
|
||||
<FaSyncAlt 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,134 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FloorplanHeightPicker } from './FloorplanHeightPicker';
|
||||
|
||||
const TRACK_HEIGHT = 260;
|
||||
|
||||
const stubTrackGeometry = (top = 0) =>
|
||||
{
|
||||
const original = HTMLDivElement.prototype.getBoundingClientRect;
|
||||
|
||||
HTMLDivElement.prototype.getBoundingClientRect = function ()
|
||||
{
|
||||
if(this.getAttribute('data-testid') === 'height-track')
|
||||
{
|
||||
return {
|
||||
top,
|
||||
left: 0,
|
||||
right: 14,
|
||||
bottom: top + TRACK_HEIGHT,
|
||||
width: 14,
|
||||
height: TRACK_HEIGHT,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ''
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
return original.call(this);
|
||||
};
|
||||
|
||||
return () =>
|
||||
{
|
||||
HTMLDivElement.prototype.getBoundingClientRect = original;
|
||||
};
|
||||
};
|
||||
|
||||
describe('FloorplanHeightPicker', () =>
|
||||
{
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders the track + thumb with the current value', () =>
|
||||
{
|
||||
render(<FloorplanHeightPicker selectedH={ 12 } onSelect={ () => undefined } />);
|
||||
|
||||
const thumb = screen.getByTestId('height-thumb');
|
||||
|
||||
expect(thumb).toBeInTheDocument();
|
||||
expect(thumb.textContent).toBe('12');
|
||||
});
|
||||
|
||||
it('clicking near the top of the track picks HEIGHT_BRUSH_MAX', () =>
|
||||
{
|
||||
const restore = stubTrackGeometry();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
|
||||
|
||||
const track = screen.getByTestId('height-track');
|
||||
|
||||
fireEvent.pointerDown(track, { clientY: 0, button: 0 });
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(26);
|
||||
|
||||
restore();
|
||||
});
|
||||
|
||||
it('clicking near the bottom of the track picks HEIGHT_BRUSH_MIN', () =>
|
||||
{
|
||||
const restore = stubTrackGeometry();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<FloorplanHeightPicker selectedH={ 26 } onSelect={ onSelect } />);
|
||||
|
||||
const track = screen.getByTestId('height-track');
|
||||
|
||||
fireEvent.pointerDown(track, { clientY: TRACK_HEIGHT, button: 0 });
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(0);
|
||||
|
||||
restore();
|
||||
});
|
||||
|
||||
it('clicking at the middle picks roughly the middle height', () =>
|
||||
{
|
||||
const restore = stubTrackGeometry();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
|
||||
|
||||
const track = screen.getByTestId('height-track');
|
||||
|
||||
fireEvent.pointerDown(track, { clientY: TRACK_HEIGHT / 2, button: 0 });
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(13);
|
||||
|
||||
restore();
|
||||
});
|
||||
|
||||
it('does not fire onSelect when the picked height equals the current selection', () =>
|
||||
{
|
||||
const restore = stubTrackGeometry();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<FloorplanHeightPicker selectedH={ 26 } onSelect={ onSelect } />);
|
||||
|
||||
const track = screen.getByTestId('height-track');
|
||||
|
||||
fireEvent.pointerDown(track, { clientY: 0, button: 0 });
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
restore();
|
||||
});
|
||||
|
||||
it('thumb fill matches the tile colour at the picked height', () =>
|
||||
{
|
||||
const { rerender } = render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ () => undefined } />);
|
||||
|
||||
const colourAtZero = screen.getByTestId('height-thumb').getAttribute('data-thumb-color');
|
||||
|
||||
rerender(<FloorplanHeightPicker selectedH={ 13 } onSelect={ () => undefined } />);
|
||||
|
||||
const colourAtThirteen = screen.getByTestId('height-thumb').getAttribute('data-thumb-color');
|
||||
|
||||
expect(colourAtZero).toBeTruthy();
|
||||
expect(colourAtThirteen).toBeTruthy();
|
||||
expect(colourAtZero).not.toBe(colourAtThirteen);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } 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 TRACK_W = 18;
|
||||
const TRACK_H = 260;
|
||||
const THUMB_DIAM = 28;
|
||||
const RAIL_GUTTER = 4;
|
||||
|
||||
const isLightColor = (hex: string): boolean =>
|
||||
{
|
||||
const c = hex.replace('#', '');
|
||||
|
||||
if(c.length !== 6) return true;
|
||||
|
||||
const r = parseInt(c.slice(0, 2), 16);
|
||||
const g = parseInt(c.slice(2, 4), 16);
|
||||
const b = parseInt(c.slice(4, 6), 16);
|
||||
const luma = (0.299 * r) + (0.587 * g) + (0.114 * b);
|
||||
|
||||
return luma > 160;
|
||||
};
|
||||
|
||||
export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||
{
|
||||
const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1;
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [ isDragging, setIsDragging ] = useState(false);
|
||||
const [ isHovering, setIsHovering ] = useState(false);
|
||||
|
||||
const gradient = useMemo(() =>
|
||||
{
|
||||
const stops: string[] = [];
|
||||
for(let i = 0; i < count; i++)
|
||||
{
|
||||
const h = HEIGHT_BRUSH_MAX - i;
|
||||
const fill = tileFill({ h, blocked: false });
|
||||
const startPct = (i / count) * 100;
|
||||
const endPct = ((i + 1) / count) * 100;
|
||||
|
||||
stops.push(`${ fill } ${ startPct.toFixed(2) }%`);
|
||||
stops.push(`${ fill } ${ endPct.toFixed(2) }%`);
|
||||
}
|
||||
|
||||
return `linear-gradient(to bottom, ${ stops.join(', ') })`;
|
||||
}, [ count ]);
|
||||
|
||||
const heightFromClientY = useCallback((clientY: number): number | null =>
|
||||
{
|
||||
const track = trackRef.current;
|
||||
|
||||
if(!track) return null;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
|
||||
if(rect.height === 0) return null;
|
||||
|
||||
const local = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const idx = Math.round(local * (count - 1));
|
||||
|
||||
return HEIGHT_BRUSH_MAX - idx;
|
||||
}, [ count ]);
|
||||
|
||||
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if(e.button !== 0) return;
|
||||
|
||||
const next = heightFromClientY(e.clientY);
|
||||
|
||||
if(next !== null && next !== selectedH) onSelect(next);
|
||||
|
||||
setIsDragging(true);
|
||||
}, [ heightFromClientY, onSelect, selectedH ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isDragging) return;
|
||||
|
||||
const onMove = (e: PointerEvent) =>
|
||||
{
|
||||
const next = heightFromClientY(e.clientY);
|
||||
if(next !== null && next !== selectedH) onSelect(next);
|
||||
};
|
||||
const onUp = () => setIsDragging(false);
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
|
||||
return () =>
|
||||
{
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
}, [ isDragging, heightFromClientY, onSelect, selectedH ]);
|
||||
|
||||
const thumbPct = ((HEIGHT_BRUSH_MAX - selectedH) / (count - 1)) * 100;
|
||||
const thumbColor = tileFill({ h: selectedH, blocked: false });
|
||||
const thumbTextDark = isLightColor(thumbColor);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0 select-none touch-none flex flex-col items-center"
|
||||
style={ { width: THUMB_DIAM + RAIL_GUTTER * 2, height: TRACK_H + 32 } }
|
||||
role="slider"
|
||||
aria-label="Brush height"
|
||||
aria-valuemin={ HEIGHT_BRUSH_MIN }
|
||||
aria-valuemax={ HEIGHT_BRUSH_MAX }
|
||||
aria-valuenow={ selectedH }
|
||||
>
|
||||
<span className="text-[9px] font-bold tabular-nums text-zinc-500 leading-none mb-1">
|
||||
{ HEIGHT_BRUSH_MAX }
|
||||
</span>
|
||||
<div className="relative flex-1" style={ { width: THUMB_DIAM } }>
|
||||
<div
|
||||
ref={ trackRef }
|
||||
data-testid="height-track"
|
||||
className={ `absolute left-1/2 -translate-x-1/2 top-0 bottom-0 rounded-full border border-zinc-400 cursor-pointer overflow-hidden transition-shadow ${ isHovering || isDragging ? 'shadow-[inset_0_0_0_1px_rgba(255,255,255,0.4),0_0_0_2px_rgba(250,204,21,0.35)]' : 'shadow-inner' }` }
|
||||
style={ {
|
||||
width: TRACK_W,
|
||||
background: gradient
|
||||
} }
|
||||
onPointerDown={ onPointerDown }
|
||||
onPointerEnter={ () => setIsHovering(true) }
|
||||
onPointerLeave={ () => setIsHovering(false) }
|
||||
/>
|
||||
<div
|
||||
data-testid="height-thumb"
|
||||
data-thumb-color={ thumbColor }
|
||||
className={ `absolute left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full flex items-center justify-center text-[11px] font-bold tabular-nums pointer-events-none transition-[box-shadow,transform] ${ thumbTextDark ? 'text-zinc-900' : 'text-white' } ${ isDragging ? 'ring-2 ring-zinc-900 scale-110' : isHovering ? 'ring-2 ring-white' : '' }` }
|
||||
style={ {
|
||||
width: THUMB_DIAM,
|
||||
height: THUMB_DIAM,
|
||||
top: `${ thumbPct }%`,
|
||||
background: `radial-gradient(circle at 32% 28%, ${ thumbTextDark ? 'rgba(255, 255, 255, 0.85)' : 'rgba(255, 255, 255, 0.55)' } 0%, ${ thumbColor } 45%, ${ thumbColor } 78%, rgba(0, 0, 0, 0.25) 100%)`,
|
||||
border: '2px solid rgba(0, 0, 0, 0.55)',
|
||||
boxShadow: '0 2px 5px rgba(0, 0, 0, 0.35), inset 0 -2px 3px rgba(0, 0, 0, 0.25), inset 0 1px 2px rgba(255, 255, 255, 0.4)',
|
||||
textShadow: thumbTextDark ? '0 1px 0 rgba(255, 255, 255, 0.6)' : '0 1px 1px rgba(0, 0, 0, 0.55)'
|
||||
} }
|
||||
>
|
||||
{ selectedH }
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold tabular-nums text-zinc-500 leading-none mt-1">
|
||||
{ HEIGHT_BRUSH_MIN }
|
||||
</span>
|
||||
</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,47 @@
|
||||
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();
|
||||
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();
|
||||
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 }>Revert</Button>
|
||||
<Button data-testid="import-load" onClick={ load }>Load</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="Rotate left"
|
||||
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={ `Direction ${ state.door.dir }/7 (click to rotate)` }
|
||||
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="Rotate right"
|
||||
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="Walls"
|
||||
value={ state.thickness.wall }
|
||||
onChange={ setWall }
|
||||
testIdPrefix="wall-thickness"
|
||||
labelKeyPrefix="navigator.roomsettings.wall_thickness"
|
||||
/>
|
||||
|
||||
<ThicknessSegmented
|
||||
label="Floors"
|
||||
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,41 @@
|
||||
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;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
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 ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!previewer) return;
|
||||
if(!tilemap) return;
|
||||
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,56 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('renders south + west side walls when h > neighbour heights', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 4, blocked: false } } selected={ false } isDoor={ false } southH={ 0 } westH={ 0 } />));
|
||||
expect(container.querySelector('[data-testid="tile-south-wall"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="tile-west-wall"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits south wall when south neighbour is at or above the tile height', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 3, blocked: false } } selected={ false } isDoor={ false } southH={ 3 } westH={ 0 } />));
|
||||
expect(container.querySelector('[data-testid="tile-south-wall"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="tile-west-wall"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits all walls for ground-level tiles', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 0, blocked: false } } selected={ false } isDoor={ false } southH={ 0 } westH={ 0 } />));
|
||||
expect(container.querySelector('[data-testid="tile-south-wall"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="tile-west-wall"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
southH?: number;
|
||||
westH?: number;
|
||||
};
|
||||
|
||||
const HEIGHT_LIFT = TILE_SIZE / 8;
|
||||
|
||||
const diamondPoints = (row: number, col: number, h: number): string =>
|
||||
{
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const cy = cyBase - h * HEIGHT_LIFT;
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
return `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
|
||||
};
|
||||
|
||||
const darkenHex = (hex: string, factor: number): string =>
|
||||
{
|
||||
const h = hex.replace('#', '');
|
||||
if(h.length !== 6) return hex;
|
||||
const r = Math.max(0, Math.floor(parseInt(h.slice(0, 2), 16) * factor));
|
||||
const g = Math.max(0, Math.floor(parseInt(h.slice(2, 4), 16) * factor));
|
||||
const b = Math.max(0, Math.floor(parseInt(h.slice(4, 6), 16) * factor));
|
||||
return `#${ [ r, g, b ].map(v => v.toString(16).padStart(2, '0')).join('') }`;
|
||||
};
|
||||
|
||||
const southWallPoints = (cx: number, cy: number, drop: number): string =>
|
||||
{
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
return `${ cx + half },${ cy } ${ cx + half },${ cy + drop } ${ cx },${ cy + quarter + drop } ${ cx },${ cy + quarter }`;
|
||||
};
|
||||
|
||||
const westWallPoints = (cx: number, cy: number, drop: number): string =>
|
||||
{
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
return `${ cx - half },${ cy } ${ cx },${ cy + quarter } ${ cx },${ cy + quarter + drop } ${ cx - half },${ cy + drop }`;
|
||||
};
|
||||
|
||||
const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor, southH = 0, westH = 0 }) =>
|
||||
{
|
||||
if(tile.blocked)
|
||||
{
|
||||
if(!selected) return null;
|
||||
const points = diamondPoints(row, col, tile.h);
|
||||
return (
|
||||
<polygon
|
||||
data-testid="selection-ring"
|
||||
points={ points }
|
||||
fill="rgba(250, 204, 21, 0.45)"
|
||||
stroke="#facc15"
|
||||
strokeWidth={ 1.5 }
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const points = diamondPoints(row, col, tile.h);
|
||||
const fill = tileFill(tile);
|
||||
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const cy = cyBase - tile.h * HEIGHT_LIFT;
|
||||
const southDrop = Math.max(0, tile.h - southH) * HEIGHT_LIFT;
|
||||
const westDrop = Math.max(0, tile.h - westH) * HEIGHT_LIFT;
|
||||
const southFill = (southDrop > 0) ? darkenHex(fill, 0.70) : null;
|
||||
const westFill = (westDrop > 0) ? darkenHex(fill, 0.55) : null;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{ southFill && (
|
||||
<polygon
|
||||
data-testid="tile-south-wall"
|
||||
points={ southWallPoints(cx, cy, southDrop) }
|
||||
fill={ southFill }
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
) }
|
||||
{ westFill && (
|
||||
<polygon
|
||||
data-testid="tile-west-wall"
|
||||
points={ westWallPoints(cx, cy, westDrop) }
|
||||
fill={ westFill }
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
) }
|
||||
<polygon
|
||||
data-row={ row }
|
||||
data-col={ col }
|
||||
points={ points }
|
||||
fill={ fill }
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
{ selected && (
|
||||
<polygon
|
||||
data-testid="selection-ring"
|
||||
data-row={ row }
|
||||
data-col={ col }
|
||||
points={ points }
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth={ 2 }
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
) }
|
||||
{ isDoor && (
|
||||
<polygon
|
||||
data-testid="door-marker"
|
||||
data-row={ row }
|
||||
data-col={ col }
|
||||
points={ points }
|
||||
fill="rgba(255,255,255,0.85)"
|
||||
stroke="#000"
|
||||
strokeWidth={ 1 }
|
||||
/>
|
||||
) }
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export const FloorplanTile = memo(FloorplanTileImpl);
|
||||
@@ -0,0 +1,54 @@
|
||||
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 dispatches SELECT_ALL + APPLY_BRUSH_TO_SELECTION (bulk-apply UX)', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('tool-select-all'));
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SELECT_ALL' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
});
|
||||
|
||||
it('square-select dispatches SQUARE_SELECT_TOGGLE', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('tool-square-select'));
|
||||
expect(dispatch).toHaveBeenCalledWith({ 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,115 @@
|
||||
import { Dispatch, FC } from 'react';
|
||||
import { FaHandPaper, 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;
|
||||
panMode?: boolean;
|
||||
setPanMode?: (next: boolean) => 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, panMode, setPanMode }) =>
|
||||
{
|
||||
const exitPan = () =>
|
||||
{
|
||||
if(panMode && setPanMode) setPanMode(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Text bold small>{ LocalizeText('floor.plan.editor.draw.mode') }</Text>
|
||||
{ setPanMode && (
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-pan"
|
||||
data-active={ panMode ? 'true' : 'false' }
|
||||
title={ panMode ? 'Hand mode active — drag to pan the view' : 'Hand mode — drag to pan the view' }
|
||||
className={ `w-7 h-7 flex items-center justify-center rounded border ${ panMode ? 'bg-emerald-500 border-emerald-700 text-white shadow-inner' : 'border-zinc-300 bg-white hover:bg-zinc-50 text-zinc-700' }` }
|
||||
onClick={ () => setPanMode(!panMode) }
|
||||
>
|
||||
<FaHandPaper size={ 12 } />
|
||||
</Base>
|
||||
) }
|
||||
{ BRUSH_BUTTONS.map(b =>
|
||||
{
|
||||
const active = state.brush.action === b.mode && !panMode;
|
||||
|
||||
return (
|
||||
<Base
|
||||
key={ b.id }
|
||||
pointer
|
||||
data-testid={ b.id }
|
||||
data-active={ active ? 'true' : 'false' }
|
||||
className={ `nitro-icon ${ b.iconClass } ${ active ? 'border border-primary' : '' }` }
|
||||
onClick={ () =>
|
||||
{
|
||||
exitPan();
|
||||
dispatch({ type: 'BRUSH_SET', action: b.mode });
|
||||
} }
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-select-all"
|
||||
className={ `nitro-icon ${ state.brush.action === 'UNSET' ? 'icon-set-deselect' : 'icon-set-select' }` }
|
||||
title={ state.brush.action === 'UNSET' ? 'Erase all tiles' : 'Apply brush to all tiles' }
|
||||
onClick={ () =>
|
||||
{
|
||||
exitPan();
|
||||
dispatch({ type: 'SELECT_ALL' });
|
||||
dispatch({ type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
} }
|
||||
/>
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-square-select"
|
||||
data-active={ state.squareSelect && !panMode ? 'true' : 'false' }
|
||||
title={ state.squareSelect && !panMode ? 'Rectangular selection mode active — drag on the canvas to apply the brush' : 'Rectangular selection — apply the brush to all tiles in an area' }
|
||||
className={ `nitro-icon icon-set-squaresselect transition-shadow ${ state.squareSelect && !panMode ? 'border-2 border-amber-500 bg-amber-400 shadow-[0_0_0_2px_rgba(245,158,11,0.45)]' : '' }` }
|
||||
onClick={ () =>
|
||||
{
|
||||
exitPan();
|
||||
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="Undo (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="Redo (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