From bf0a73eaf8f338a3e2c96b99c923e0dca2e50834 Mon Sep 17 00:00:00 2001 From: duckietm Date: Tue, 26 May 2026 16:38:01 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=95=20Brand=20new=20Floorplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floorplan-editor/FloorplanEditorView.tsx | 21 +-- .../hooks/useFloorplanReducer.ts | 37 ----- .../hooks/usePointerToTile.test.ts | 2 - .../hooks/usePointerToTile.ts | 39 +++-- .../floorplan-editor/hooks/useTool.test.ts | 33 ++++ .../floorplan-editor/hooks/useTool.ts | 36 ++++- .../floorplan-editor/state/constants.ts | 1 - .../floorplan-editor/state/encoding.ts | 3 +- .../floorplan-editor/state/reducer.test.ts | 147 +++++++++++++++++- .../floorplan-editor/state/reducer.ts | 66 +++++++- .../floorplan-editor/state/selectors.test.ts | 3 - .../floorplan-editor/state/types.ts | 1 + .../views/FloorplanCanvasSVG.test.tsx | 2 - .../views/FloorplanCanvasSVG.tsx | 105 +++++++------ .../views/FloorplanHeightPicker.test.tsx | 8 - .../views/FloorplanHeightPicker.tsx | 35 +---- .../views/FloorplanImportExport.test.tsx | 2 - .../views/FloorplanImportExport.tsx | 4 +- .../views/FloorplanOptionsPanel.tsx | 10 +- .../views/FloorplanRoomPreview.tsx | 27 ---- .../views/FloorplanTile.test.tsx | 21 +++ .../floorplan-editor/views/FloorplanTile.tsx | 88 ++++++++++- .../views/FloorplanToolbar.test.tsx | 13 +- .../views/FloorplanToolbar.tsx | 23 +-- 24 files changed, 482 insertions(+), 245 deletions(-) diff --git a/src/components/floorplan-editor/FloorplanEditorView.tsx b/src/components/floorplan-editor/FloorplanEditorView.tsx index 2e27638..5a53899 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.tsx @@ -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.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 = () => onWallHeightChange(state.wallHeight + 1) } /> - Area: { area.total } ({ area.walkable } caselle) + Area: { area.total } ({ area.walkable } tiles) 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)" > { liveSync ? 'Live preview ON' : 'Live preview OFF' } diff --git a/src/components/floorplan-editor/hooks/useFloorplanReducer.ts b/src/components/floorplan-editor/hooks/useFloorplanReducer.ts index 9d85cff..183e22f 100644 --- a/src/components/floorplan-editor/hooks/useFloorplanReducer.ts +++ b/src/components/floorplan-editor/hooks/useFloorplanReducer.ts @@ -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([]); const futureRef = useRef([]); const [ canUndo, setCanUndo ] = useState(false); const [ canRedo, setCanRedo ] = useState(false); const stateRef = useRef(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 ''; diff --git a/src/components/floorplan-editor/hooks/usePointerToTile.test.ts b/src/components/floorplan-editor/hooks/usePointerToTile.test.ts index e478ea8..0a605f0 100644 --- a/src/components/floorplan-editor/hooks/usePointerToTile.test.ts +++ b/src/components/floorplan-editor/hooks/usePointerToTile.test.ts @@ -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); diff --git a/src/components/floorplan-editor/hooks/usePointerToTile.ts b/src/components/floorplan-editor/hooks/usePointerToTile.ts index 6da475f..7f065a8 100644 --- a/src/components/floorplan-editor/hooks/usePointerToTile.ts +++ b/src/components/floorplan-editor/hooks/usePointerToTile.ts @@ -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 ]); }; diff --git a/src/components/floorplan-editor/hooks/useTool.test.ts b/src/components/floorplan-editor/hooks/useTool.test.ts index 41360c3..9862925 100644 --- a/src/components/floorplan-editor/hooks/useTool.test.ts +++ b/src/components/floorplan-editor/hooks/useTool.test.ts @@ -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, 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, projection)); + act(() => result.current.onPointerUp({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { releasePointerCapture: () => {} } } as never)); + expect(dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/floorplan-editor/hooks/useTool.ts b/src/components/floorplan-editor/hooks/useTool.ts index e8ed57e..db94db3 100644 --- a/src/components/floorplan-editor/hooks/useTool.ts +++ b/src/components/floorplan-editor/hooks/useTool.ts @@ -36,6 +36,7 @@ export const useTool = ( { const isDownRef = useRef(false); const lastTileRef = useRef(null); + const squareStartRef = useRef<{ row: number; col: number } | null>(null); const apply = useCallback((e: PointerEvent) => { @@ -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) => { 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) => { 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 }; }; diff --git a/src/components/floorplan-editor/state/constants.ts b/src/components/floorplan-editor/state/constants.ts index 559fcfa..334a0b0 100644 --- a/src/components/floorplan-editor/state/constants.ts +++ b/src/components/floorplan-editor/state/constants.ts @@ -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 }; diff --git a/src/components/floorplan-editor/state/encoding.ts b/src/components/floorplan-editor/state/encoding.ts index 08fb97a..3f416f4 100644 --- a/src/components/floorplan-editor/state/encoding.ts +++ b/src/components/floorplan-editor/state/encoding.ts @@ -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 => { diff --git a/src/components/floorplan-editor/state/reducer.test.ts b/src/components/floorplan-editor/state/reducer.test.ts index a6b7e09..689e9ad 100644 --- a/src/components/floorplan-editor/state/reducer.test.ts +++ b/src/components/floorplan-editor/state/reducer.test.ts @@ -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', () => diff --git a/src/components/floorplan-editor/state/reducer.ts b/src/components/floorplan-editor/state/reducer.ts index ba62ef4..876afcb 100644 --- a/src/components/floorplan-editor/state/reducer.ts +++ b/src/components/floorplan-editor/state/reducer.ts @@ -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': diff --git a/src/components/floorplan-editor/state/selectors.test.ts b/src/components/floorplan-editor/state/selectors.test.ts index f885055..b918e00 100644 --- a/src/components/floorplan-editor/state/selectors.test.ts +++ b/src/components/floorplan-editor/state/selectors.test.ts @@ -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 }] diff --git a/src/components/floorplan-editor/state/types.ts b/src/components/floorplan-editor/state/types.ts index ce536ce..1361e38 100644 --- a/src/components/floorplan-editor/state/types.ts +++ b/src/components/floorplan-editor/state/types.ts @@ -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 } diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx index b205a71..bb61ab5 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx @@ -15,7 +15,6 @@ describe('FloorplanCanvasSVG', () => ] }; const { container } = render( {} } />); - // 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(); 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(); diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx index 19411a9..55e9c60 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx @@ -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; - /** - * 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 = ({ 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 = ({ 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 = ({ 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 = ({ state, dispatch, panMode }) => { const isDoor = state.door.x === c && state.door.y === r && !tile.blocked; const selected = state.selection.has(`${ r },${ c }`); - return ; + 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 ; }); return { cells }; }), [ 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( + + ); + } + return out.length ? { out } : 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 = ({ 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 = ({ state, dispatch, panMode }) => { @@ -253,12 +245,13 @@ export const FloorplanCanvasSVG: FC = ({ state, dispatch, panMode }) => } } > { rows } + { outOfBoundsOverlay }
+
); diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx index f29f1fe..380586b 100644 --- a/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx @@ -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( undefined } />); const colourAtZero = screen.getByTestId('height-thumb').getAttribute('data-thumb-color'); diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx index b8dfd28..d1c2196 100644 --- a/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx @@ -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 = ({ selectedH, onSelect }) => { const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1; @@ -135,7 +109,7 @@ export const FloorplanHeightPicker: FC = ({ 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 = ({ 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)', diff --git a/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx b/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx index 6b5c810..6aa4daa 100644 --- a/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx @@ -18,7 +18,6 @@ describe('FloorplanImportExport', () => render( {} } 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( {} } 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(); diff --git a/src/components/floorplan-editor/views/FloorplanImportExport.tsx b/src/components/floorplan-editor/views/FloorplanImportExport.tsx index 0063f10..c965703 100644 --- a/src/components/floorplan-editor/views/FloorplanImportExport.tsx +++ b/src/components/floorplan-editor/views/FloorplanImportExport.tsx @@ -43,8 +43,8 @@ export const FloorplanImportExport: FC = ({ state, dispatch, onClose, onS onChange={ e => setRaw(e.target.value) } />
- - + +
diff --git a/src/components/floorplan-editor/views/FloorplanOptionsPanel.tsx b/src/components/floorplan-editor/views/FloorplanOptionsPanel.tsx index d41cb26..602ad77 100644 --- a/src/components/floorplan-editor/views/FloorplanOptionsPanel.tsx +++ b/src/components/floorplan-editor/views/FloorplanOptionsPanel.tsx @@ -29,7 +29,7 @@ export const FloorplanOptionsPanel: FC = ({ state, dispatch }) => setDir(rotateDir(state.door.dir, -1)) } > @@ -38,14 +38,14 @@ export const FloorplanOptionsPanel: FC = ({ state, dispatch }) => setDir(rotateDir(state.door.dir, 1)) } /> setDir(rotateDir(state.door.dir, 1)) } > @@ -55,7 +55,7 @@ export const FloorplanOptionsPanel: FC = ({ state, dispatch }) =>
= ({ state, dispatch }) => /> = ({ state, height = 320 }) => { const [ previewer, setPreviewer ] = useState(null); @@ -49,16 +28,10 @@ export const FloorplanRoomPreview: FC = ({ 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 ]); diff --git a/src/components/floorplan-editor/views/FloorplanTile.test.tsx b/src/components/floorplan-editor/views/FloorplanTile.test.tsx index 5309a7d..6f79d5b 100644 --- a/src/components/floorplan-editor/views/FloorplanTile.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanTile.test.tsx @@ -32,4 +32,25 @@ describe('FloorplanTile', () => const { container } = render(svg()); expect(container.querySelector('[data-testid="selection-ring"]')).toBeTruthy(); }); + + it('renders south + west side walls when h > neighbour heights', () => + { + const { container } = render(svg()); + 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()); + 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()); + expect(container.querySelector('[data-testid="tile-south-wall"]')).toBeNull(); + expect(container.querySelector('[data-testid="tile-west-wall"]')).toBeNull(); + }); }); diff --git a/src/components/floorplan-editor/views/FloorplanTile.tsx b/src/components/floorplan-editor/views/FloorplanTile.tsx index 4a5848b..7fca484 100644 --- a/src/components/floorplan-editor/views/FloorplanTile.tsx +++ b/src/components/floorplan-editor/views/FloorplanTile.tsx @@ -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 = ({ 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 = ({ row, col, tile, selected, isDoor, southH = 0, westH = 0 }) => +{ + if(tile.blocked) + { + if(!selected) return null; + const points = diamondPoints(row, col, tile.h); + return ( + + ); + } 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 ( - + { southFill && ( + + ) } + { westFill && ( + + ) } + { selected && ( = ({ row, col, tile, selected, isDoor }) => { isDoor && ( 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(); 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(); + fireEvent.click(getByTestId('tool-square-select')); + expect(dispatch).toHaveBeenCalledWith({ type: 'SQUARE_SELECT_TOGGLE' }); }); it('marks active brush button with data-active', () => diff --git a/src/components/floorplan-editor/views/FloorplanToolbar.tsx b/src/components/floorplan-editor/views/FloorplanToolbar.tsx index a2aebc4..5cc3563 100644 --- a/src/components/floorplan-editor/views/FloorplanToolbar.tsx +++ b/src/components/floorplan-editor/views/FloorplanToolbar.tsx @@ -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 = ({ 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 = ({ state, dispatch, canUndo, canRedo, pointer data-testid="tool-pan" data-active={ panMode ? 'true' : 'false' } - title={ panMode ? 'Modalità mano attiva — trascina per spostare la vista' : 'Modalità mano — trascina per spostare la vista' } + 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) } > @@ -77,18 +67,21 @@ export const FloorplanToolbar: FC = ({ state, dispatch, canUndo, canRedo, 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' }); } } /> { exitPan(); @@ -100,7 +93,7 @@ export const FloorplanToolbar: FC = ({ state, dispatch, canUndo, canRedo, @@ -109,7 +102,7 @@ export const FloorplanToolbar: FC = ({ state, dispatch, canUndo, canRedo,