🆕 Brand new Floorplan

This commit is contained in:
duckietm
2026-05-26 16:38:01 +02:00
parent 4f0a8be2b0
commit bf0a73eaf8
24 changed files with 482 additions and 245 deletions
@@ -41,13 +41,6 @@ export const FloorplanEditorView: FC = () =>
const area = useMemo(() => areaCount(state.tiles), [ state.tiles ]);
// Live in-room preview: while the editor is open every tile /
// door / thickness / wallHeight change is applied immediately
// to the 3D room behind the editor card, CLIENT-SIDE ONLY (no
// server packet). The wire UpdateFloorPropertiesMessageComposer
// is only sent when the user clicks Save. `setBaseline` is
// called by the message handlers below so the hook knows what
// state to roll back to if the user closes without saving.
const { setBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state });
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, () => setIsVisible(false));
@@ -110,9 +103,6 @@ export const FloorplanEditorView: FC = () =>
thicknessFloor: originalRef.current.thicknessFloor,
wallHeight: parser.wallHeight + 1
});
// Anchor the live-sync baseline at the server's authoritative
// snapshot so the first re-render after this load doesn't
// bounce the same model back as an "edit".
setBaseline({
tilemap: parser.model,
doorX: originalRef.current.entryPoint[0],
@@ -140,10 +130,6 @@ export const FloorplanEditorView: FC = () =>
dispatch({ type: 'SET_THICKNESS', wall, floor, source: 'remote' });
});
// Keyboard shortcuts: Ctrl+Z = undo, Ctrl+Shift+Z / Ctrl+Y = redo.
// Scoped to when the editor is visible; ignored when focus is in
// a text-entry field (Import/Export modal textarea, wall height
// input) so we don't fight the OS-native undo.
useEffect(() =>
{
if(!isVisible) return;
@@ -214,9 +200,6 @@ export const FloorplanEditorView: FC = () =>
const o = originalRef.current;
if(!o) return;
loadFromServer(o);
// Roll the live in-room preview back to the server-known
// baseline. No-op if live sync is off (nothing was changed
// in the room).
if(liveSync) revertLivePreview();
};
@@ -254,14 +237,14 @@ export const FloorplanEditorView: FC = () =>
<FaCaretRight className="cursor-pointer fa-icon text-zinc-600" onClick={ () => onWallHeightChange(state.wallHeight + 1) } />
</Flex>
<Text bold small className="text-zinc-700">
Area: <span className="tabular-nums">{ area.total }</span> ({ area.walkable } caselle)
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="Anteprima locale nella stanza mentre disegni (non salva al server)"
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>
@@ -21,10 +21,6 @@ type Api = {
canRedo: boolean;
};
// Actions that DON'T change the room model — they only affect the
// editor's UI state (brush selection, drag-select rectangle, …) and
// should NOT push a new history snapshot. Brushing a tile, moving a
// door, changing thickness, etc. all DO push history.
const isNonHistoryAction = (action: FloorplanAction): boolean =>
{
switch(action.type)
@@ -40,10 +36,6 @@ const isNonHistoryAction = (action: FloorplanAction): boolean =>
}
};
// Remote-driven actions also bypass history — they represent the
// "true" server state, not a user edit. Treating a server push as
// a history step would let the user "undo" a server snapshot, which
// makes no sense.
const isRemoteAction = (action: FloorplanAction): boolean =>
{
if(action.type === 'APPLY_REMOTE_DIFF' || action.type === 'APPLY_REMOTE_SNAPSHOT') return true;
@@ -56,23 +48,12 @@ export const useFloorplanReducer = (): Api =>
{
const [ state, dispatch ] = useReducer(reducer, initialState);
// Past / future stacks — paired with `state` to form a linear
// timeline (`past` ++ [state] ++ `future`). Refs because the
// wrappedDispatch closure needs the latest value but we don't
// want every push to trigger a re-render. canUndo / canRedo are
// separately tracked as React state so the UI buttons disable
// correctly.
const pastRef = useRef<FloorplanState[]>([]);
const futureRef = useRef<FloorplanState[]>([]);
const [ canUndo, setCanUndo ] = useState(false);
const [ canRedo, setCanRedo ] = useState(false);
const stateRef = useRef<FloorplanState>(state);
// Keep stateRef in sync with the latest committed render so the
// history pushers (which run inside callbacks, not during
// render) always see the up-to-date state. Writing the ref
// inside an effect — not directly in the render body — is what
// React's `refs-during-render` rule enforces.
useEffect(() =>
{
stateRef.current = state;
@@ -92,8 +73,6 @@ export const useFloorplanReducer = (): Api =>
return;
}
// Local edit: push current state onto past, drop future
// (any redo branch is invalidated by a new edit).
pastRef.current.push(stateRef.current);
if(pastRef.current.length > HISTORY_LIMIT) pastRef.current.shift();
@@ -106,7 +85,6 @@ export const useFloorplanReducer = (): Api =>
const loadFromServer = useCallback((s: ServerFloorSettings) =>
{
// Server load wipes history — the document is fresh.
pastRef.current = [];
futureRef.current = [];
dispatch({
@@ -133,15 +111,6 @@ export const useFloorplanReducer = (): Api =>
thickness: previous.thickness,
wallHeight: previous.wallHeight,
seq: previous.seq });
// The APPLY_REMOTE_SNAPSHOT action re-parses the tilemap;
// but we also want to restore brush/selection state. Wrap
// the dispatch in an effect-like immediate sync by writing
// through stateRef AFTER React commits — handled by the
// next render setting stateRef. The selection/brush carried
// by `previous` is recovered on the next mutating dispatch
// since the reducer's APPLY_REMOTE_SNAPSHOT path resets
// selection (acceptable: undoing a paint clears the
// selection rectangle, which matches user intuition).
refreshCanFlags();
}, [ refreshCanFlags ]);
@@ -166,12 +135,6 @@ export const useFloorplanReducer = (): Api =>
}), [ state, wrappedDispatch, loadFromServer, undo, redo, canUndo, canRedo ]);
};
// Local serializer mirror — the reducer's APPLY_REMOTE_SNAPSHOT
// path takes a raw tilemap string, but our history entries are
// the live Tile[][] arrays. Re-emit `\r`-joined rows in the same
// shape the encoding module uses for SAVES (we keep this here to
// avoid a circular import: state/reducer already imports
// state/encoding).
const serializeTilesForSnapshot = (tiles: { h: number; blocked: boolean }[][]): string =>
{
if(!tiles || tiles.length === 0) return '';
@@ -22,8 +22,6 @@ describe('tileToScreen / screenToTile round-trip', () =>
it('rounds to the containing diamond for jittered points', () =>
{
// The diamond for tile (R, C) is centered at tileToScreen(R, C).
// Small jitter inside the diamond should still resolve to the same tile under round-to-nearest.
const [ sx, sy ] = tileToScreen(7, 2);
const [ r, c ] = screenToTile(sx + 2, sy + 1);
expect(Math.round(r)).toBe(7);
@@ -29,24 +29,39 @@ export const usePointerToTile = (
viewBox: ViewBox
): PointerProjection =>
{
const { width, height, x: viewX = 0, y: viewY = 0 } = viewBox;
void viewBox;
const fromClient = useCallback((clientX: number, clientY: number) =>
{
const svg = svgRef.current;
if(!svg) return null;
const rect = svg.getBoundingClientRect();
if(rect.width === 0 || rect.height === 0) return null;
// Map screen-space pointer onto the viewBox interior, then
// shift by the viewBox origin — when zoomed in the viewBox
// starts at (viewX, viewY) instead of (0, 0), so a pointer
// at the left edge of the SVG corresponds to viewX in
// local SVG units, not 0.
const localX = viewX + ((clientX - rect.left) / rect.width) * width;
const localY = viewY + ((clientY - rect.top) / rect.height) * height;
const [ row, col ] = screenToTile(localX, localY);
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, width, height, viewX, viewY ]);
}, [ svgRef ]);
return useMemo(() => ({ fromClient }), [ fromClient ]);
};
@@ -84,4 +84,37 @@ describe('useTool', () =>
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();
});
});
@@ -36,6 +36,7 @@ export const useTool = (
{
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>) =>
{
@@ -52,22 +53,49 @@ export const useTool = (
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 ]);
}, [ apply, state.squareSelect, projection, dispatch ]);
const onPointerMove = useCallback((e: PointerEvent<SVGSVGElement>) =>
{
if(!isDownRef.current) return;
if(state.brush.action === 'DOOR') return; // door is a single-click placement
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 ]);
}, [ 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 };
};
@@ -11,5 +11,4 @@ export const MAX_WALL_HEIGHT = 16;
export const HEIGHT_BRUSH_MIN = 0;
export const HEIGHT_BRUSH_MAX = 26;
// Empty (uninitialized) door used as initial state until a server event arrives.
export const EMPTY_DOOR = { x: 0, y: 0, dir: 2 as const };
@@ -1,10 +1,9 @@
import { Tile } from './types';
import { HEIGHT_SCHEME } from './constants';
// 'x0123456789abcdefghijklmnopq' (28 chars total)
const VALID_CHARS = HEIGHT_SCHEME;
const HMIN = 0;
const HMAX = VALID_CHARS.length - 2; // 26
const HMAX = VALID_CHARS.length - 2;
export const charToTile = (ch: string): Tile =>
{
@@ -2,6 +2,7 @@ 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,
@@ -180,17 +181,32 @@ describe('reducer — BRUSH_SET', () =>
describe('reducer — selection', () =>
{
it('SELECT_ALL marks every non-blocked tile', () =>
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(3);
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(false);
expect(next.selection.has('1,1')).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', () =>
@@ -203,7 +219,6 @@ describe('reducer — selection', () =>
it('SELECT_RECT marks the rectangle inclusive', () =>
{
const start = stateWith(defaultEmptyTilemap(4, 4));
// First populate non-blocked tiles so SELECT_RECT picks them up
const populated = {
...start,
tiles: start.tiles.map(row => row.map(() => ({ h: 0, blocked: false })))
@@ -213,6 +228,27 @@ describe('reducer — selection', () =>
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' });
@@ -220,6 +256,107 @@ describe('reducer — selection', () =>
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', () =>
@@ -116,11 +116,11 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
case 'SELECT_ALL':
{
const sel = new Set<`${number},${number}`>();
for(let r = 0; r < state.tiles.length; r++)
for(let r = 0; r < MAX_NUM_TILE_PER_AXIS; r++)
{
for(let c = 0; c < (state.tiles[r]?.length ?? 0); c++)
for(let c = 0; c < MAX_NUM_TILE_PER_AXIS; c++)
{
if(!state.tiles[r][c].blocked) sel.add(`${r},${c}`);
sel.add(`${r},${c}`);
}
}
return { ...state, selection: sel };
@@ -131,18 +131,72 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
{
const [ r0, c0 ] = action.from;
const [ r1, c1 ] = action.to;
const rMin = Math.min(r0, r1), rMax = Math.max(r0, r1);
const cMin = Math.min(c0, c1), cMax = Math.max(c0, c1);
const 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++)
{
if(state.tiles[r]?.[c] && !state.tiles[r][c].blocked) sel.add(`${r},${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':
@@ -34,9 +34,6 @@ describe('areaCount', () =>
it('treats blocked tiles as non-tiles (per existing UI semantics)', () =>
{
// In the original implementation, height === 'x' was the marker for "not a tile".
// total counts placed tiles only (i.e. !blocked), walkable equals total since blocked are excluded.
// This matches the legacy calculateArea() behaviour where blocked tiles were skipped entirely.
const grid = [
[{ h: 0, blocked: true }, { h: 0, blocked: true }, { h: 0, blocked: true }],
[{ h: 0, blocked: false }, { h: 1, blocked: false }, { h: 0, blocked: true }]
@@ -43,6 +43,7 @@ export type FloorplanAction =
| { 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 }
@@ -15,7 +15,6 @@ describe('FloorplanCanvasSVG', () =>
]
};
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ () => {} } />);
// 3 non-blocked tiles → 3 base polygons (plus possibly selection/door extras)
const polys = container.querySelectorAll('polygon');
expect(polys.length).toBeGreaterThanOrEqual(3);
});
@@ -41,7 +40,6 @@ describe('FloorplanCanvasSVG', () =>
const dispatch = vi.fn();
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />);
const svg = container.querySelector('svg') as SVGSVGElement;
// jsdom getBoundingClientRect returns zeros; we need to stub it so projection works.
svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) });
fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 });
expect(dispatch).toHaveBeenCalled();
@@ -1,5 +1,5 @@
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { FaCrosshairs, FaSearchMinus, FaSearchPlus } from 'react-icons/fa';
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';
@@ -9,12 +9,6 @@ import { TILE_SIZE, MAX_NUM_TILE_PER_AXIS } from '../state/constants';
type Props = {
state: FloorplanState;
dispatch: Dispatch<FloorplanAction>;
/**
* When true, left-click + drag pans the canvas instead of
* brushing. Driven by the hand-tool toggle in the toolbar.
* Shift+drag and middle-mouse drag always pan regardless of
* this flag.
*/
panMode?: boolean;
};
@@ -24,21 +18,10 @@ const VIEWBOX_H = (MAX_NUM_TILE_PER_AXIS * TILE_SIZE) / 2;
const ZOOM_MIN = 0.4;
const ZOOM_MAX = 6;
const ZOOM_STEP = 0.2;
// Slack around the room bounding box when auto-fitting, so the tiles
// don't sit flush against the canvas edge.
const FIT_PADDING = TILE_SIZE * 2;
const clampZoom = (z: number): number => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
/**
* Compute the screen-space bounding box of the painted (= non-
* blocked) tiles. Returns `null` if the room is fully blocked /
* empty — caller can fall back to the centered default view.
*
* tileToScreen returns the TOP corner of the iso diamond; we
* inflate by half a tile in every direction so the diamond's
* extremities (left/right/bottom points) are included.
*/
const computeRoomBounds = (state: FloorplanState): { x: number; y: number; w: number; h: number } | null =>
{
let minX = Infinity;
@@ -89,19 +72,12 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
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);
// First-paint flag: once we've seen a non-empty room we
// auto-fit (zoom in/out until the room fills the canvas with
// a small margin) exactly once. Manual zoom/pan afterwards is
// preserved.
const centeredRef = useRef(false);
const roomBounds = useMemo(() => computeRoomBounds(state), [ state.tiles ]); // eslint-disable-line react-hooks/exhaustive-deps
// Pan a given zoom level so the room centre sits in the
// viewport centre. With zoom kept, the formula reduces to
// `roomCenter - VIEWBOX_center` because the (VIEWBOX - visible)
// / 2 base offset terms cancel.
const centerPanForRoom = useCallback((): { x: number; y: number } | null =>
{
if(!roomBounds) return null;
@@ -111,10 +87,6 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
};
}, [ roomBounds ]);
// Fit-to-room: zooms IN/OUT until the whole room is visible
// (with a 5 % margin), then centres the pan. This is the
// default view — running on first paint and on every click of
// the %% / 'reset' label.
const fitToRoom = useCallback(() =>
{
if(!roomBounds) return;
@@ -127,12 +99,6 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
setPan(next);
}, [ roomBounds, centerPanForRoom ]);
// Auto-fit the FIRST time we see a non-empty room (typically
// right after the server-driven load). The literal 100 % zoom
// leaves too much empty space around small rooms, so the
// 'default view' is fit-to-room (~95 % of the smaller axis
// so tiles don't sit flush against the edge). The user's
// subsequent manual zoom / pan adjustments are preserved.
useEffect(() =>
{
if(centeredRef.current) return;
@@ -158,17 +124,47 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
{
const isDoor = state.door.x === c && state.door.y === r && !tile.blocked;
const selected = state.selection.has(`${ r },${ c }`);
return <FloorplanTile key={ `${ r }-${ c }` } row={ r } col={ c } tile={ tile } selected={ selected } isDoor={ isDoor } />;
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)), []);
// The %% label button restores the default view: fit-to-room
// (the same auto-fit that runs on first paint). Clicking it
// after manual zoom always gets you back to "room fills the
// canvas, room centred".
const resetView = useCallback(() =>
{
fitToRoom();
@@ -211,11 +207,6 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
};
}, [ visW ]);
// Pan gestures: middle-mouse, Shift+left-click, and (when the
// hand-tool is active) plain left-click. The hand-tool toggle
// is the toolbar affordance — Shift / middle still work even
// when the hand isn't on, so power users keep their muscle
// memory.
const isPanGesture = (e: ReactPointerEvent): boolean =>
e.button === 1
|| (e.button === 0 && e.shiftKey)
@@ -228,7 +219,8 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
<svg
ref={ svgRef }
viewBox={ viewBox }
className={ `w-full h-full select-none rounded-md border border-zinc-300 bg-[url('@/assets/images/floorplaneditor/canvas_floor_pattern.png')] bg-repeat [image-rendering:pixelated] ${ cursorClass }` }
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 =>
{
@@ -253,12 +245,13 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
} }
>
{ 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="Riduci (Ctrl+rotellina)"
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 }
@@ -268,7 +261,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
<button
type="button"
data-testid="zoom-reset"
title="Inquadra la stanza"
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 }
>
@@ -277,7 +270,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
<button
type="button"
data-testid="zoom-in"
title="Ingrandisci (Ctrl+rotellina)"
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 }
@@ -288,7 +281,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
<button
type="button"
data-testid="zoom-recenter"
title="Centra sulla stanza (mantiene lo zoom)"
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={ () =>
@@ -299,6 +292,16 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
>
<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>
);
@@ -4,9 +4,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { FloorplanHeightPicker } from './FloorplanHeightPicker';
// Force a fixed track size into getBoundingClientRect so the
// pointer-y -> height math is reproducible regardless of jsdom's
// layout (which would otherwise hand back zeroes).
const TRACK_HEIGHT = 260;
const stubTrackGeometry = (top = 0) =>
@@ -99,8 +96,6 @@ describe('FloorplanHeightPicker', () =>
fireEvent.pointerDown(track, { clientY: TRACK_HEIGHT / 2, button: 0 });
// (1 - 0.5) * 26 = 13. The exact value depends on Math.round,
// which here lands on 13 for a half-track click.
expect(onSelect).toHaveBeenCalledWith(13);
restore();
@@ -124,9 +119,6 @@ describe('FloorplanHeightPicker', () =>
it('thumb fill matches the tile colour at the picked height', () =>
{
// h=0 is solid blue (#0065ff in COLORMAP). Re-render at a
// different height and assert the recorded thumb colour
// changes — i.e., the thumb tracks the band underneath.
const { rerender } = render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ () => undefined } />);
const colourAtZero = screen.getByTestId('height-thumb').getAttribute('data-thumb-color');
@@ -12,12 +12,6 @@ const TRACK_H = 260;
const THUMB_DIAM = 28;
const RAIL_GUTTER = 4;
/**
* Perceptual-luminance heuristic. Returns true if a hex colour is
* 'light enough' that black text reads better than white. Uses the
* Rec. 601 luma coefficients — good enough for a UI affordance,
* cheap to compute, no dep on a colour lib.
*/
const isLightColor = (hex: string): boolean =>
{
const c = hex.replace('#', '');
@@ -32,26 +26,6 @@ const isLightColor = (hex: string): boolean =>
return luma > 160;
};
/**
* Vertical brush-height slider.
*
* Track - discrete-step gradient built from the real tile-fill
* colours, top = HEIGHT_BRUSH_MAX, bottom = HEIGHT_BRUSH_MIN.
* Each height owns a clear band so colour <-> height stays
* legible at a glance, exactly like the swatch column it
* replaces.
* Min/max - small chip labels float above and below the rail so the
* user knows what the endpoints mean without trial and
* error.
* Thumb - amber radial gradient on a soft drop shadow, white ring
* when hovered, darker ring while dragging. Renders the
* current value in the middle so the user reads the
* number directly off the handle.
* Gesture - click the rail to jump, click-and-drag the thumb (or
* rail) to scrub. Window-level pointer listeners keep
* the drag alive even when the cursor leaves the narrow
* strip. Vertical scroll on touch is suppressed.
*/
export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
{
const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1;
@@ -135,7 +109,7 @@ export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
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="Altezza pennello"
aria-label="Brush height"
aria-valuemin={ HEIGHT_BRUSH_MIN }
aria-valuemax={ HEIGHT_BRUSH_MAX }
aria-valuenow={ selectedH }
@@ -164,13 +138,6 @@ export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
width: THUMB_DIAM,
height: THUMB_DIAM,
top: `${ thumbPct }%`,
// Thumb fill picks up the colour of the band
// under it — visual continuity with the
// gradient so users see the colour of the
// height they're picking, not a generic
// amber chip. Radial highlight + bottom
// shadow give it a beaded look without
// hiding the underlying colour.
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)',
@@ -18,7 +18,6 @@ describe('FloorplanImportExport', () =>
render(<FloorplanImportExport state={ state } dispatch={ () => {} } onClose={ () => {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />);
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
expect(ta).toBeTruthy();
// Textarea normalizes \r to \n
expect(ta.value).toBe('01\nx2');
});
@@ -28,7 +27,6 @@ describe('FloorplanImportExport', () =>
render(<FloorplanImportExport state={ initialState } dispatch={ dispatch } onClose={ () => {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />);
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
expect(ta).toBeTruthy();
// Textarea normalizes \r to \n
fireEvent.change(ta, { target: { value: 'xq\n00' } });
const button = document.querySelector('[data-testid="import-load"]') as HTMLButtonElement;
expect(button).toBeTruthy();
@@ -43,8 +43,8 @@ export const FloorplanImportExport: FC<Props> = ({ state, dispatch, onClose, onS
onChange={ e => setRaw(e.target.value) }
/>
<div className="flex gap-2 justify-end">
<Button data-testid="import-revert" onClick={ revert }>Annulla</Button>
<Button data-testid="import-load" onClick={ load }>Carica</Button>
<Button data-testid="import-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>
@@ -29,7 +29,7 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
<Base
data-testid="entry-dir-prev"
pointer
title="Ruota a sinistra"
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)) }
>
@@ -38,14 +38,14 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
<Base
data-testid="entry-dir"
pointer
title={ `Direzione ${ state.door.dir }/7 (click per ruotare)` }
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="Ruota a destra"
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)) }
>
@@ -55,7 +55,7 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
</Flex>
<ThicknessSegmented
label="Pareti"
label="Walls"
value={ state.thickness.wall }
onChange={ setWall }
testIdPrefix="wall-thickness"
@@ -63,7 +63,7 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
/>
<ThicknessSegmented
label="Pavimenti"
label="Floors"
value={ state.thickness.floor }
onChange={ setFloor }
testIdPrefix="floor-thickness"
@@ -6,30 +6,9 @@ import { FloorplanState } from '../state/types';
type Props = {
state: FloorplanState;
/** Outer container height; the previewer fills the parent width. */
height?: number;
};
/**
* Textured isometric room preview driven by the renderer's
* RoomPreviewer (the same engine the catalog uses for furniture
* thumbnails). Whenever the editor's tilemap / wallHeight changes,
* `RoomPreviewer.updatePreviewModel` re-renders the floor with
* actual sand/plaster textures — far closer to what the room will
* look like in-game than the previous SVG-on-black preview.
*
* IMPORTANT — construction lives INSIDE the lifecycle effect, not
* in a lazy `useState` initializer. RoomPreviewer.dispose() nulls
* out internal fields (`_planeParser`, `_backgroundSprite`, …), so
* once we've disposed an instance any subsequent
* `updatePreviewModel` call on it crashes with "this._planeParser
* is null". React 19 StrictMode runs each effect setup → cleanup →
* setup again on first mount in dev: a lazy useState would hand
* the same disposed instance to the second setup. By creating the
* previewer inside the effect and writing it to state, the
* StrictMode re-run gets a fresh instance — matching the pattern
* useCatalog already uses for the same renderer object.
*/
export const FloorplanRoomPreview: FC<Props> = ({ state, height = 320 }) =>
{
const [ previewer, setPreviewer ] = useState<RoomPreviewer | null>(null);
@@ -49,16 +28,10 @@ export const FloorplanRoomPreview: FC<Props> = ({ state, height = 320 }) =>
const tilemap = useMemo(() => serializeTilemap(state.tiles), [ state.tiles ]);
// Push the current editor model into the previewer whenever it
// changes. updatePreviewModel re-runs the same plane-parser +
// ObjectRoomMapUpdateMessage pipeline as the in-room
// applyFloorModelLocally, so the textured preview matches the
// live in-room preview pixel-for-pixel.
useEffect(() =>
{
if(!previewer) return;
if(!tilemap) return;
// server-space wall height: editor stores 1+, wire is 0-based
previewer.updatePreviewModel(tilemap, Math.max(0, state.wallHeight - 1), true);
}, [ previewer, tilemap, state.wallHeight ]);
@@ -32,4 +32,25 @@ describe('FloorplanTile', () =>
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();
});
});
@@ -10,29 +10,105 @@ type Props = {
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 * (TILE_SIZE / 8);
const cy = cyBase - h * HEIGHT_LIFT;
const half = TILE_SIZE / 2;
const quarter = TILE_SIZE / 4;
// Diamond corners: top, right, bottom, left
return `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
};
const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor }) =>
const darkenHex = (hex: string, factor: number): string =>
{
if(tile.blocked) return null;
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>
<polygon points={ points } fill={ fill } stroke="#222" strokeWidth={ 0.5 } />
{ 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"
@@ -43,6 +119,8 @@ const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor }) =>
{ isDoor && (
<polygon
data-testid="door-marker"
data-row={ row }
data-col={ col }
points={ points }
fill="rgba(255,255,255,0.85)"
stroke="#000"
@@ -27,14 +27,21 @@ describe('FloorplanToolbar', () =>
expect(types).toEqual([ 'UNSET', 'UP', 'DOWN', 'DOOR' ]);
});
it('select-all and square-select dispatch their actions', () =>
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'));
fireEvent.click(getByTestId('tool-square-select'));
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SELECT_ALL' });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SQUARE_SELECT_TOGGLE' });
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', () =>
@@ -12,13 +12,6 @@ type Props = {
onUndo?: () => void;
onRedo?: () => void;
panMode?: boolean;
/**
* Imperative setter for pan mode. Receiving the explicit
* value (not a toggle) lets every tool button switch the
* hand off on click without needing to know its current
* state — the hand is part of the same exclusive tool group
* as the brushes, so picking any brush has to clear it.
*/
setPanMode?: (next: boolean) => void;
};
@@ -32,9 +25,6 @@ const BRUSH_BUTTONS: { id: string; mode: FloorActionMode; iconClass: string }[]
export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo, onUndo, onRedo, panMode, setPanMode }) =>
{
// The hand and the brush buttons form a single exclusive tool
// group. Picking ANY other tool clears pan mode so the user
// never ends up in 'I clicked SET but the canvas still pans'.
const exitPan = () =>
{
if(panMode && setPanMode) setPanMode(false);
@@ -48,7 +38,7 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
pointer
data-testid="tool-pan"
data-active={ panMode ? 'true' : 'false' }
title={ panMode ? 'Modalità mano attivatrascina per spostare la vista' : 'Modalità manotrascina per spostare la vista' }
title={ panMode ? 'Hand mode activedrag to pan the view' : 'Hand modedrag 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) }
>
@@ -77,18 +67,21 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
<Base
pointer
data-testid="tool-select-all"
className={ `nitro-icon ${ state.selection.size > 0 ? 'icon-set-deselect' : 'icon-set-select' }` }
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' }
className={ `nitro-icon icon-set-squaresselect ${ state.squareSelect && !panMode ? 'border border-primary' : '' }` }
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();
@@ -100,7 +93,7 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
<Base
pointer={ Boolean(canUndo) }
data-testid="tool-undo"
title="Annulla (Ctrl+Z)"
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 }
>
@@ -109,7 +102,7 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
<Base
pointer={ Boolean(canRedo) }
data-testid="tool-redo"
title="Ripeti (Ctrl+Shift+Z)"
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 }
>