Phase 3: room features (modes, room size, doors, wall screens, obstacles, import/export)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
A web tool for venues (events, banquets, conferences) to lay out furniture in a room and, eventually, auto-optimize the layout.
|
||||
|
||||
## Current scope: Phase 1 + Phase 2 (UI only)
|
||||
## Current scope: Phases 1-3 (UI only)
|
||||
|
||||
Layout and editing UI. No optimizer yet.
|
||||
|
||||
@@ -22,7 +22,14 @@ Layout and editing UI. No optimizer yet.
|
||||
- Screens: while selected, unlocked and unrotated, left/right end handles change their length (`resizeLength`, min 0.5 m). Thickness is only changed by size wheels, never handles (top-down view).
|
||||
- Custom furniture: "Custom" tile opens a form (name, Seating/Table, width, length, seats, available). Saving adds a config + palette tile. Custom seating draws as a chair, custom tables as a table.
|
||||
|
||||
Out of scope: room features (doors, room screen), room import, placement rules, optimization, scanning. Do not design in a way that blocks the roadmap below.
|
||||
**Room features (Phase 3)**
|
||||
- A Layout / Room toggle sits at the top of the sidebar. Switching closes menus and deselects. In Room mode furniture is visible but can't be moved or selected.
|
||||
- Room panel: width and length wheels (3-60 m, step 0.1; the room grows/shrinks from its top-left). It can't shrink past the bounding box of placed furniture, obstacles and wall items (`minRoomSize`; anything else that would clash is rejected via `tryResizeRoom` and the wheel glides back). Add door / Add screen (max 2) / Add obstacle, Export room, Import room.
|
||||
- Walls are drawn inside the canvas (`WALL_PX`); a door is a gap in the wall plus a quarter-circle swing arc. Doors and wall screens slide along their own wall only (no corner-hopping to adjacent walls). Door panel: width 0.7-4 m, "Loading door" toggle, delete (the last door can't be deleted). Wall-screen panel: width 1-12 m, delete. Obstacles (default 0.6 x 0.6 m, 0.2-10 m) drag anywhere; width/length wheels, delete. Delete/Backspace also deletes the selected room element.
|
||||
- Keep-clear zones: the door swing square and the 0.6 m strip inside the opening, the 0.3 m strip under a wall screen, and obstacles. `getBlockedRects(room)` returns them all and `isValidPlacement` reads it, so furniture can't overlap them (red outline, drop blocked). Doors/screens can't sit on furniture or obstacles or share wall length; obstacles can't overlap furniture, each other, or door/screen zones.
|
||||
- Export downloads `room.json` (width, height, doors, screens, obstacles; no furniture). Import validates with `parseRoom` (`roomSchema.ts`) and shows a clear error; if furniture is placed it asks for confirmation first, and if the placed furniture doesn't fit the imported room it clears all furniture and warns.
|
||||
|
||||
Out of scope: non-rectangular rooms, floor plan image import, phone scanning, placement rules, scoring, optimization. Do not design in a way that blocks the roadmap below.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -48,7 +55,12 @@ interface FurnitureItem {
|
||||
seatsOverride?: number; // tables: overrides config.seatsPerUnit
|
||||
}
|
||||
|
||||
interface Room { width: number; height: number; } // meters
|
||||
type Wall = "top" | "bottom" | "left" | "right";
|
||||
// offset = meters from the wall's start (top/bottom start at the left, left/right at the top) to the near edge
|
||||
interface Door { id: string; wall: Wall; offset: number; width: number; isLoading: boolean; }
|
||||
interface RoomScreen { id: string; wall: Wall; offset: number; width: number; } // spec calls it "Screen"; renamed to avoid clashing with the screen furniture type / DOM Screen
|
||||
interface Obstacle { id: string; x: number; y: number; width: number; height: number; }
|
||||
interface Room { width: number; height: number; doors: Door[]; screens: RoomScreen[]; obstacles: Obstacle[]; } // meters
|
||||
|
||||
interface FurnitureTypeConfig {
|
||||
id: FurnitureType; name: string; shape: Shape;
|
||||
@@ -62,7 +74,7 @@ interface FurnitureTypeConfig {
|
||||
// seats for an item = item.seatsOverride ?? config.seatsPerUnit (effectiveSeats)
|
||||
```
|
||||
|
||||
Store (`store.ts`): `{ room, items, configs: Record<id, config>, typeOrder: id[] }`. Defaults live in the configs (chair 0.5x0.5, table 1.8x0.8 w/ 8 seats, screen 2x0.2). Chairs and table seats are independent.
|
||||
Store (`store.ts`; `setRoom` replaces the room, optionally clearing furniture): `{ room, items, configs: Record<id, config>, typeOrder: id[] }`. Defaults live in the configs (chair 0.5x0.5, table 1.8x0.8 w/ 8 seats, screen 2x0.2). Chairs and table seats are independent.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -74,9 +86,9 @@ Store (`store.ts`): `{ room, items, configs: Record<id, config>, typeOrder: id[]
|
||||
|
||||
## File layout
|
||||
|
||||
- `src/types.ts`; `src/geometry.ts` (pure: `boundsOf`, `clampToRoom`, `overlaps`, `isValidPlacement`, `tryUpdate`, `resizeLength`, `fitScale`); `src/inventory.ts` (pure: `placedCount`, `remaining`, `effectiveSeats`); `src/store.ts` (reducer + built-in configs).
|
||||
- `src/types.ts`; `src/geometry.ts` (pure: `boundsOf`, `clampToRoom`, `overlaps`, `isValidPlacement`, `tryUpdate`, `resizeLength`, `fitScale`, plus room rules: `getBlockedRects`, `isValidWallItem`, `isValidObstacle`, `isLayoutValid`, `minRoomSize`, `tryResizeRoom`, `findWallSpot`, `findObstacleSpot`); `src/roomSchema.ts` (pure: `parseRoom`, `exportRoom`); `src/inventory.ts` (pure: `placedCount`, `remaining`, `effectiveSeats`); `src/store.ts` (reducer + built-in configs).
|
||||
- `src/App.tsx`: owns the store, selection, sidebar mode, palette drag, room scale, Delete key.
|
||||
- `src/components/`: `Palette`, `TypeMenu`, `ItemPanel`, `CustomForm`, `Wheel`, `Room` (Konva), `FurnitureShape`, `Icons`, `ScaleBar`, `panelStyles`.
|
||||
- `src/components/`: `Palette`, `TypeMenu`, `ItemPanel`, `CustomForm`, `ModeToggle`, `RoomPanels`, `Wheel`, `Room` (Konva), `FurnitureShape`, `Icons`, `ScaleBar`, `panelStyles`.
|
||||
|
||||
## Roadmap (not built yet, don't block)
|
||||
|
||||
@@ -103,6 +115,6 @@ npx tsc --noEmit # type check
|
||||
|
||||
## Working agreement
|
||||
|
||||
- Ask before expanding scope past Phase 2.
|
||||
- Ask before expanding scope past Phase 3.
|
||||
- After changes, make sure the app builds and type checks.
|
||||
- Hackathon pace: prefer simple and working over clever.
|
||||
+194
-8
@@ -1,17 +1,34 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { clampToRoom, fitScale, isValidPlacement, tryUpdate } from "./geometry";
|
||||
import {
|
||||
DEFAULT_DOOR_WIDTH,
|
||||
MAX_SCREENS,
|
||||
clampToRoom,
|
||||
findObstacleSpot,
|
||||
findWallSpot,
|
||||
fitScale,
|
||||
isLayoutValid,
|
||||
isValidObstacle,
|
||||
isValidPlacement,
|
||||
isValidWallItem,
|
||||
minRoomSize,
|
||||
tryResizeRoom,
|
||||
tryUpdate,
|
||||
wallLength,
|
||||
} from "./geometry";
|
||||
import { exportRoom, parseRoom } from "./roomSchema";
|
||||
import { initialState, reducer } from "./store";
|
||||
import type { FurnitureItem, FurnitureType, Rect } from "./types";
|
||||
import type { Door, FurnitureItem, FurnitureType, Obstacle, Rect, Room as RoomT, RoomScreen, Wall } from "./types";
|
||||
import { placedCount, remaining } from "./inventory";
|
||||
import { Palette } from "./components/Palette";
|
||||
import { Room, type Preview } from "./components/Room";
|
||||
import { Room, WALL_PX, type Mode, type Preview, type RoomSel } from "./components/Room";
|
||||
import { ModeToggle } from "./components/ModeToggle";
|
||||
import { DoorPanel, ObstaclePanel, RoomPanel, ScreenPanel } from "./components/RoomPanels";
|
||||
import { ScaleBar } from "./components/ScaleBar";
|
||||
import { TypeMenu } from "./components/TypeMenu";
|
||||
import { ItemPanel } from "./components/ItemPanel";
|
||||
import { CustomForm, type CustomDraft } from "./components/CustomForm";
|
||||
import { ShapeIcon } from "./components/Icons";
|
||||
|
||||
const WALL_PX = 6; // room wall border, must match Room.tsx
|
||||
const SCALE_BAR_PX = 40;
|
||||
|
||||
interface PaletteDrag {
|
||||
@@ -30,7 +47,12 @@ export default function App() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const floorRef = useRef<HTMLDivElement>(null);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const roomRef = useRef(state.room);
|
||||
roomRef.current = state.room;
|
||||
const [pxPerM, setPxPerM] = useState(60);
|
||||
const [mode, setMode] = useState<Mode>("layout");
|
||||
const [roomSel, setRoomSel] = useState<RoomSel | null>(null);
|
||||
const [roomNotice, setRoomNotice] = useState("");
|
||||
const nextItemId = useRef(1);
|
||||
const nextCustomId = useRef(1);
|
||||
|
||||
@@ -48,6 +70,19 @@ export default function App() {
|
||||
const closeSidebar = useCallback(() => setSidebar({ kind: "tiles" }), []);
|
||||
const deselect = useCallback(() => setSelectedId(null), []);
|
||||
|
||||
// Switching modes closes menus and clears every selection.
|
||||
const switchMode = (m: Mode) => {
|
||||
setMode(m);
|
||||
setSelectedId(null);
|
||||
setRoomSel(null);
|
||||
setRoomNotice("");
|
||||
setSidebar({ kind: "tiles" });
|
||||
};
|
||||
const deselectRoom = useCallback(() => {
|
||||
setRoomSel(null);
|
||||
setRoomNotice("");
|
||||
}, []);
|
||||
|
||||
const deleteSelected = useCallback(() => {
|
||||
if (!selectedId) return;
|
||||
dispatch({ kind: "remove", id: selectedId });
|
||||
@@ -60,13 +95,19 @@ export default function App() {
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
if (roomSel) {
|
||||
e.preventDefault();
|
||||
deleteRoomSel();
|
||||
return;
|
||||
}
|
||||
if (!selectedId) return;
|
||||
e.preventDefault();
|
||||
deleteSelected();
|
||||
};
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [selectedId, deleteSelected]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, deleteSelected, roomSel, state.room]);
|
||||
|
||||
// Maximise the room inside the center area (minus wall border and the scale bar).
|
||||
useEffect(() => {
|
||||
@@ -74,11 +115,11 @@ export default function App() {
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
setPxPerM(fitScale(state.room, width - WALL_PX * 2, height - WALL_PX * 2 - SCALE_BAR_PX));
|
||||
setPxPerM(fitScale(roomRef.current, width - WALL_PX * 2, height - WALL_PX * 2 - SCALE_BAR_PX));
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [state.room]);
|
||||
}, [state.room.width, state.room.height]);
|
||||
|
||||
// Cursor position -> a new item's box (centered on cursor, clamped), or null if outside the floor.
|
||||
const toRoomRect = (d: PaletteDrag): Rect | null => {
|
||||
@@ -151,6 +192,116 @@ export default function App() {
|
||||
return true;
|
||||
};
|
||||
|
||||
// ---- Room mode: every edit builds a new Room with pure rules, then replaces it in the store ----
|
||||
const room = state.room;
|
||||
const setRoom = (next: RoomT) => {
|
||||
dispatch({ kind: "setRoom", room: next });
|
||||
setRoomNotice("");
|
||||
};
|
||||
const reject = (msg: string) => setRoomNotice(msg);
|
||||
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const resizeRoom = (size: { width?: number; height?: number }) => {
|
||||
const next = tryResizeRoom(room, state.items, size);
|
||||
if (next) setRoom(next);
|
||||
else reject("Can't shrink past what's placed in the room.");
|
||||
};
|
||||
|
||||
const addDoor = () => {
|
||||
for (const wall of ["bottom", "right", "left", "top"] as Wall[]) {
|
||||
const offset = findWallSpot(room, state.items, "door", wall, DEFAULT_DOOR_WIDTH);
|
||||
if (offset === null) continue;
|
||||
const door: Door = { id: uid("door"), wall, offset, width: DEFAULT_DOOR_WIDTH, isLoading: false };
|
||||
setRoom({ ...room, doors: [...room.doors, door] });
|
||||
setRoomSel({ kind: "door", id: door.id });
|
||||
return;
|
||||
}
|
||||
reject("No free space on any wall for a door.");
|
||||
};
|
||||
|
||||
const addScreen = () => {
|
||||
if (room.screens.length >= MAX_SCREENS) return;
|
||||
for (const wall of ["top", "left", "right", "bottom"] as Wall[]) {
|
||||
const width = Math.min(4, wallLength(room, wall));
|
||||
const offset = findWallSpot(room, state.items, "screen", wall, width);
|
||||
if (offset === null) continue;
|
||||
const screen: RoomScreen = { id: uid("screen"), wall, offset, width };
|
||||
setRoom({ ...room, screens: [...room.screens, screen] });
|
||||
setRoomSel({ kind: "screen", id: screen.id });
|
||||
return;
|
||||
}
|
||||
reject("No free space on any wall for a screen.");
|
||||
};
|
||||
|
||||
const addObstacle = () => {
|
||||
const spot = findObstacleSpot(room, state.items, 0.6, 0.6);
|
||||
if (!spot) return reject("No free space for an obstacle.");
|
||||
const o: Obstacle = { id: uid("obstacle"), ...spot, width: 0.6, height: 0.6 };
|
||||
setRoom({ ...room, obstacles: [...room.obstacles, o] });
|
||||
setRoomSel({ kind: "obstacle", id: o.id });
|
||||
};
|
||||
|
||||
const patchDoor = (id: string, patch: Partial<Door>) => {
|
||||
const d = room.doors.find((x) => x.id === id);
|
||||
if (!d) return;
|
||||
const next = { ...d, ...patch };
|
||||
next.offset = Math.max(0, Math.min(next.offset, wallLength(room, next.wall) - next.width));
|
||||
if (!isValidWallItem(room, state.items, { id, kind: "door", wall: next.wall, offset: next.offset, width: next.width }))
|
||||
return reject("No room for that change.");
|
||||
setRoom({ ...room, doors: room.doors.map((x) => (x.id === id ? next : x)) });
|
||||
};
|
||||
|
||||
const patchScreen = (id: string, patch: Partial<RoomScreen>) => {
|
||||
const sc = room.screens.find((x) => x.id === id);
|
||||
if (!sc) return;
|
||||
const next = { ...sc, ...patch };
|
||||
next.offset = Math.max(0, Math.min(next.offset, wallLength(room, next.wall) - next.width));
|
||||
if (!isValidWallItem(room, state.items, { id, kind: "screen", wall: next.wall, offset: next.offset, width: next.width }))
|
||||
return reject("No room for that change.");
|
||||
setRoom({ ...room, screens: room.screens.map((x) => (x.id === id ? next : x)) });
|
||||
};
|
||||
|
||||
const patchObstacle = (id: string, patch: Partial<Obstacle>) => {
|
||||
const o = room.obstacles.find((x) => x.id === id);
|
||||
if (!o) return;
|
||||
const next = clampToRoom({ ...o, ...patch }, room);
|
||||
if (!isValidObstacle(room, state.items, next)) return reject("No room for that change.");
|
||||
setRoom({ ...room, obstacles: room.obstacles.map((x) => (x.id === id ? next : x)) });
|
||||
};
|
||||
|
||||
const deleteRoomSel = () => {
|
||||
if (!roomSel) return;
|
||||
if (roomSel.kind === "door") {
|
||||
if (room.doors.length <= 1) return reject("A room needs at least one door.");
|
||||
setRoom({ ...room, doors: room.doors.filter((d) => d.id !== roomSel.id) });
|
||||
} else if (roomSel.kind === "screen") {
|
||||
setRoom({ ...room, screens: room.screens.filter((s) => s.id !== roomSel.id) });
|
||||
} else {
|
||||
setRoom({ ...room, obstacles: room.obstacles.filter((o) => o.id !== roomSel.id) });
|
||||
}
|
||||
setRoomSel(null);
|
||||
};
|
||||
|
||||
const exportRoomFile = () => {
|
||||
const url = URL.createObjectURL(new Blob([exportRoom(room)], { type: "application/json" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "room.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importRoomFile = async (file: File) => {
|
||||
const result = parseRoom(await file.text());
|
||||
if (!result.ok) return reject(`Import failed: ${result.error}`);
|
||||
if (state.items.length > 0 && !window.confirm("Importing replaces the current room. Continue?")) return;
|
||||
const clear = state.items.length > 0 && !isLayoutValid(result.room, state.items);
|
||||
dispatch({ kind: "setRoom", room: result.room, clearItems: clear });
|
||||
setSelectedId(null);
|
||||
setRoomSel(null);
|
||||
setRoomNotice(clear ? "Furniture was cleared because it didn't fit the imported room." : "");
|
||||
};
|
||||
|
||||
const saveCustom = (d: CustomDraft) => {
|
||||
const id = `custom-${nextCustomId.current++}`;
|
||||
dispatch({
|
||||
@@ -175,7 +326,33 @@ export default function App() {
|
||||
);
|
||||
|
||||
let panel = null;
|
||||
if (selectedItem) {
|
||||
if (mode === "room") {
|
||||
const door = roomSel?.kind === "door" ? room.doors.find((d) => d.id === roomSel.id) : undefined;
|
||||
const screen = roomSel?.kind === "screen" ? room.screens.find((s) => s.id === roomSel.id) : undefined;
|
||||
const obstacle = roomSel?.kind === "obstacle" ? room.obstacles.find((o) => o.id === roomSel.id) : undefined;
|
||||
if (door) {
|
||||
panel = (
|
||||
<DoorPanel key={door.id} door={door} isLast={room.doors.length <= 1} notice={roomNotice}
|
||||
onPatch={(p) => patchDoor(door.id, p)} onDelete={deleteRoomSel} onClose={deselectRoom} />
|
||||
);
|
||||
} else if (screen) {
|
||||
panel = (
|
||||
<ScreenPanel key={screen.id} screen={screen} notice={roomNotice}
|
||||
onPatch={(p) => patchScreen(screen.id, p)} onDelete={deleteRoomSel} onClose={deselectRoom} />
|
||||
);
|
||||
} else if (obstacle) {
|
||||
panel = (
|
||||
<ObstaclePanel key={obstacle.id} obstacle={obstacle} notice={roomNotice}
|
||||
onPatch={(p) => patchObstacle(obstacle.id, p)} onDelete={deleteRoomSel} onClose={deselectRoom} />
|
||||
);
|
||||
} else {
|
||||
panel = (
|
||||
<RoomPanel room={room} minSize={minRoomSize(room, state.items)} notice={roomNotice}
|
||||
onResize={resizeRoom} onAddDoor={addDoor} onAddScreen={addScreen} onAddObstacle={addObstacle}
|
||||
onExport={exportRoomFile} onImport={importRoomFile} />
|
||||
);
|
||||
}
|
||||
} else if (selectedItem) {
|
||||
panel = (
|
||||
<ItemPanel
|
||||
key={selectedItem.id}
|
||||
@@ -209,6 +386,7 @@ export default function App() {
|
||||
onStartDrag={startDrag}
|
||||
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
|
||||
onNewCustom={() => openSidebar({ kind: "custom" })}
|
||||
header={<ModeToggle mode={mode} onChange={switchMode} />}
|
||||
panel={panel}
|
||||
/>
|
||||
<main
|
||||
@@ -216,6 +394,14 @@ export default function App() {
|
||||
style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", overflow: "hidden", padding: 24 }}
|
||||
>
|
||||
<Room
|
||||
mode={mode}
|
||||
roomSel={roomSel}
|
||||
onSelectRoom={(sel) => {
|
||||
setRoomSel(sel);
|
||||
setRoomNotice("");
|
||||
}}
|
||||
onMoveWallItem={(kind, id, offset) => (kind === "door" ? patchDoor(id, { offset }) : patchScreen(id, { offset }))}
|
||||
onMoveObstacle={(id, x, y) => patchObstacle(id, { x, y })}
|
||||
pxPerM={pxPerM}
|
||||
room={state.room}
|
||||
items={state.items}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Mode } from "./Room";
|
||||
|
||||
export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||
const seg = (m: Mode): React.CSSProperties => ({
|
||||
flex: 1,
|
||||
height: 30,
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
font: "600 13px system-ui, sans-serif",
|
||||
background: mode === m ? "#fff" : "transparent",
|
||||
color: mode === m ? "#222" : "#666",
|
||||
boxShadow: mode === m ? "0 1px 3px rgba(0,0,0,0.15)" : "none",
|
||||
});
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1", display: "flex", gap: 2, padding: 3, background: "#d9d9d9", borderRadius: 9 }}>
|
||||
<button style={seg("layout")} onClick={() => onChange("layout")}>
|
||||
Layout
|
||||
</button>
|
||||
<button style={seg("room")} onClick={() => onChange("room")}>
|
||||
Room
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
|
||||
onOpenMenu: (type: FurnitureType) => void;
|
||||
onNewCustom: () => void;
|
||||
header?: ReactNode; // always shown first (mode toggle)
|
||||
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ function NewCustomTile({ onClick }: { onClick: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
|
||||
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, header, panel }: Props) {
|
||||
const [width, setWidth] = useState(264);
|
||||
|
||||
const startResize = (e: PointerEvent) => {
|
||||
@@ -158,6 +159,7 @@ export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{panel ?? (
|
||||
<>
|
||||
{types.map((cfg) => (
|
||||
|
||||
+287
-19
@@ -1,8 +1,19 @@
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Stage, Layer, Group, Rect, Path } from "react-konva";
|
||||
import { Stage, Layer, Group, Rect, Path, Arc, Line, RegularPolygon } from "react-konva";
|
||||
import type Konva from "konva";
|
||||
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
|
||||
import {
|
||||
SCREEN_DEPTH_M,
|
||||
boundsOf,
|
||||
clampToRoom,
|
||||
doorSwingRect,
|
||||
isValidObstacle,
|
||||
isValidPlacement,
|
||||
isValidWallItem,
|
||||
resizeLength,
|
||||
wallLength,
|
||||
wallRect,
|
||||
} from "../geometry";
|
||||
import type { Door, FurnitureItem, FurnitureTypeConfig, Obstacle, Rect as RectM, Room as RoomT, RoomScreen, Shape, Wall } from "../types";
|
||||
import { FurnitureShape, type Outline } from "./FurnitureShape";
|
||||
|
||||
export interface Preview {
|
||||
@@ -14,7 +25,16 @@ export interface Preview {
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export const WALL_PX = 6; // drawn wall thickness around the floor
|
||||
export type Mode = "layout" | "room";
|
||||
export type RoomSel = { kind: "door" | "screen" | "obstacle"; id: string };
|
||||
|
||||
interface Props {
|
||||
mode: Mode;
|
||||
roomSel: RoomSel | null;
|
||||
onSelectRoom: (sel: RoomSel | null) => void;
|
||||
onMoveWallItem: (kind: "door" | "screen", id: string, offset: number) => void;
|
||||
onMoveObstacle: (id: string, x: number, y: number) => void;
|
||||
room: RoomT;
|
||||
items: FurnitureItem[];
|
||||
configs: Record<string, FurnitureTypeConfig>;
|
||||
@@ -38,7 +58,99 @@ interface ResizeInfo {
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
interface WallDrag {
|
||||
kind: "door" | "screen";
|
||||
id: string;
|
||||
offset: number;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
const HANDLE_PX = 10;
|
||||
const O = WALL_PX;
|
||||
const WALL_COLOR = "#2b2b2b";
|
||||
const SEL = "#1c7ed6";
|
||||
const BAD = "#e03131";
|
||||
const GOOD = "#2f9e44";
|
||||
|
||||
/** Wall pieces between door openings, in meters along each wall. */
|
||||
function wallSegments(room: RoomT): { wall: Wall; a: number; b: number }[] {
|
||||
const out: { wall: Wall; a: number; b: number }[] = [];
|
||||
for (const wall of ["top", "bottom", "left", "right"] as Wall[]) {
|
||||
const len = wallLength(room, wall);
|
||||
const gaps = room.doors.filter((d) => d.wall === wall).sort((p, q) => p.offset - q.offset);
|
||||
let at = 0;
|
||||
for (const g of gaps) {
|
||||
if (g.offset > at + 1e-6) out.push({ wall, a: at, b: g.offset });
|
||||
at = Math.max(at, g.offset + g.width);
|
||||
}
|
||||
if (at < len - 1e-6) out.push({ wall, a: at, b: len });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Quarter-circle swing: hinge at the door's near end, leaf swings into the room.
|
||||
const SWING = {
|
||||
top: { rot: 0, dir: [0, 1] },
|
||||
bottom: { rot: 270, dir: [0, -1] },
|
||||
left: { rot: 0, dir: [1, 0] },
|
||||
right: { rot: 90, dir: [-1, 0] },
|
||||
} as const;
|
||||
|
||||
function hinge(room: RoomT, d: Pick<Door, "wall" | "offset">): [number, number] {
|
||||
switch (d.wall) {
|
||||
case "top":
|
||||
return [d.offset, 0];
|
||||
case "bottom":
|
||||
return [d.offset, room.height];
|
||||
case "left":
|
||||
return [0, d.offset];
|
||||
case "right":
|
||||
return [room.width, d.offset];
|
||||
}
|
||||
}
|
||||
|
||||
function DoorShape({ room, door, px, color }: { room: RoomT; door: Door; px: number; color: string }) {
|
||||
const [hx, hy] = hinge(room, door);
|
||||
const sw = SWING[door.wall];
|
||||
const r = door.width * px;
|
||||
return (
|
||||
<Group listening={false}>
|
||||
<Arc
|
||||
x={hx * px}
|
||||
y={hy * px}
|
||||
innerRadius={0}
|
||||
outerRadius={r}
|
||||
angle={90}
|
||||
rotation={sw.rot}
|
||||
fill={door.isLoading ? "rgba(237,137,54,0.2)" : "rgba(66,153,225,0.14)"}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
dash={[4, 4]}
|
||||
/>
|
||||
<Line points={[hx * px, hy * px, hx * px + sw.dir[0] * r, hy * px + sw.dir[1] * r]} stroke={color} strokeWidth={3} lineCap="round" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const ARROW_ROT: Record<Wall, number> = { top: 180, bottom: 0, left: 90, right: -90 };
|
||||
|
||||
function ScreenShape({ room, screen, px, outline }: { room: RoomT; screen: RoomScreen; px: number; outline: string }) {
|
||||
const r = wallRect(room, screen.wall, screen.offset, screen.width, SCREEN_DEPTH_M);
|
||||
const rad = Math.max(4, SCREEN_DEPTH_M * px * 0.5);
|
||||
return (
|
||||
<Group listening={false}>
|
||||
<Rect x={r.x * px} y={r.y * px} width={r.width * px} height={r.height * px} fill="#2d3748" stroke={outline} strokeWidth={outline === WALL_COLOR ? 1 : 2.5} cornerRadius={2} />
|
||||
<RegularPolygon
|
||||
x={(r.x + r.width / 2) * px}
|
||||
y={(r.y + r.height / 2) * px}
|
||||
sides={3}
|
||||
radius={rad}
|
||||
rotation={ARROW_ROT[screen.wall]}
|
||||
fill="#63b3ed"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
const ENDS: ("left" | "right")[] = ["left", "right"];
|
||||
|
||||
function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }) {
|
||||
@@ -52,10 +164,14 @@ function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }
|
||||
);
|
||||
}
|
||||
|
||||
export function Room({ room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
|
||||
export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstacle, room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
|
||||
const interactive = mode === "layout";
|
||||
const [drag, setDrag] = useState<DragInfo | null>(null);
|
||||
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
||||
const resizeStart = useRef<RectM | null>(null);
|
||||
const [wallDrag, setWallDrag] = useState<WallDrag | null>(null);
|
||||
const wallStart = useRef<{ along: number; offset: number } | null>(null);
|
||||
const [obDrag, setObDrag] = useState<{ id: string; valid: boolean } | null>(null);
|
||||
const w = room.width * pxPerM;
|
||||
const h = room.height * pxPerM;
|
||||
|
||||
@@ -70,26 +186,167 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
room,
|
||||
);
|
||||
|
||||
const fw = w + O * 2;
|
||||
const fh = h + O * 2;
|
||||
|
||||
// ---- wall items (doors / screens): slide along their wall only ----
|
||||
const alongOf = (wall: Wall, p: { x: number; y: number }) => ((wall === "top" || wall === "bottom" ? p.x : p.y) - O) / pxPerM;
|
||||
|
||||
const wallHit = (kind: "door" | "screen", id: string, wall: Wall, offset: number, width: number, rect: RectM) => {
|
||||
return (
|
||||
<Rect
|
||||
key={`hit-${id}`}
|
||||
x={rect.x * pxPerM}
|
||||
y={rect.y * pxPerM}
|
||||
width={rect.width * pxPerM}
|
||||
height={rect.height * pxPerM}
|
||||
fill="transparent"
|
||||
draggable
|
||||
dragBoundFunc={function (this: Konva.Node) {
|
||||
return this.absolutePosition();
|
||||
}}
|
||||
onMouseDown={() => onSelectRoom({ kind, id })}
|
||||
onTouchStart={() => onSelectRoom({ kind, id })}
|
||||
onDragStart={(e) => {
|
||||
e.cancelBubble = true;
|
||||
const p = e.target.getStage()?.getPointerPosition();
|
||||
if (p) wallStart.current = { along: alongOf(wall, p), offset };
|
||||
}}
|
||||
onDragMove={(e) => {
|
||||
const p = e.target.getStage()?.getPointerPosition();
|
||||
const st = wallStart.current;
|
||||
if (!p || !st) return;
|
||||
const next = Math.min(Math.max(st.offset + alongOf(wall, p) - st.along, 0), wallLength(room, wall) - width);
|
||||
setWallDrag({ kind, id, offset: next, valid: isValidWallItem(room, items, { id, kind, wall, offset: next, width }) });
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (wallDrag?.valid) onMoveWallItem(kind, id, wallDrag.offset); // else snap back
|
||||
setWallDrag(null);
|
||||
wallStart.current = null;
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = wall === "top" || wall === "bottom" ? "ew-resize" : "ns-resize";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const colorFor = (kind: "door" | "screen", id: string, normal: string) => {
|
||||
if (wallDrag?.kind === kind && wallDrag.id === id) return wallDrag.valid ? GOOD : BAD;
|
||||
return roomSel?.kind === kind && roomSel.id === id ? SEL : normal;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={floorRef}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
border: "6px solid #2b2b2b",
|
||||
boxSizing: "content-box",
|
||||
width: fw,
|
||||
height: fh,
|
||||
background: "#fff",
|
||||
boxShadow: "0 4px 18px rgba(0,0,0,0.12)",
|
||||
touchAction: "none",
|
||||
}}
|
||||
ref={floorRef}
|
||||
>
|
||||
<Stage
|
||||
width={w}
|
||||
height={h}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
width={fw}
|
||||
height={fh}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target !== e.target.getStage()) return;
|
||||
if (interactive) onSelect(null);
|
||||
else onSelectRoom(null);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (e.target !== e.target.getStage()) return;
|
||||
if (interactive) onSelect(null);
|
||||
else onSelectRoom(null);
|
||||
}}
|
||||
>
|
||||
<Layer>
|
||||
<Layer x={O} y={O}>
|
||||
<Rect width={w} height={h} fill="#fff" listening={false} />
|
||||
|
||||
{/* walls with a gap at every door */}
|
||||
{wallSegments(room).map((sg, n) => {
|
||||
const a = sg.a * pxPerM;
|
||||
const len = (sg.b - sg.a) * pxPerM;
|
||||
const r =
|
||||
sg.wall === "top"
|
||||
? { x: a, y: -O, width: len, height: O }
|
||||
: sg.wall === "bottom"
|
||||
? { x: a, y: h, width: len, height: O }
|
||||
: sg.wall === "left"
|
||||
? { x: -O, y: a, width: O, height: len }
|
||||
: { x: w, y: a, width: O, height: len };
|
||||
return <Rect key={n} {...r} fill={WALL_COLOR} listening={false} />;
|
||||
})}
|
||||
{[[-O, -O], [w, -O], [-O, h], [w, h]].map(([x, y]) => (
|
||||
<Rect key={`${x},${y}`} x={x} y={y} width={O} height={O} fill={WALL_COLOR} listening={false} />
|
||||
))}
|
||||
|
||||
{/* doors: swing arc inside the room */}
|
||||
{room.doors.map((d) => {
|
||||
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? { ...d, offset: wallDrag.offset } : d;
|
||||
return <DoorShape key={d.id} room={room} door={live} px={pxPerM} color={colorFor("door", d.id, d.isLoading ? "#c05621" : "#4a5568")} />;
|
||||
})}
|
||||
|
||||
{/* wall screens */}
|
||||
{room.screens.map((sc) => {
|
||||
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? { ...sc, offset: wallDrag.offset } : sc;
|
||||
return <ScreenShape key={sc.id} room={room} screen={live} px={pxPerM} outline={colorFor("screen", sc.id, WALL_COLOR)} />;
|
||||
})}
|
||||
|
||||
{/* obstacles */}
|
||||
{room.obstacles.map((o: Obstacle) => {
|
||||
const state = obDrag?.id === o.id ? (obDrag.valid ? GOOD : BAD) : roomSel?.kind === "obstacle" && roomSel.id === o.id ? SEL : "#4a4a4a";
|
||||
return (
|
||||
<Rect
|
||||
key={o.id}
|
||||
x={o.x * pxPerM}
|
||||
y={o.y * pxPerM}
|
||||
width={o.width * pxPerM}
|
||||
height={o.height * pxPerM}
|
||||
fill="#9aa0a6"
|
||||
stroke={state}
|
||||
strokeWidth={state === "#4a4a4a" ? 1.5 : 3}
|
||||
cornerRadius={2}
|
||||
listening={!interactive}
|
||||
draggable={!interactive}
|
||||
onMouseDown={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
||||
onTouchStart={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
||||
dragBoundFunc={(pos) => ({
|
||||
x: Math.min(Math.max(pos.x, O), O + w - o.width * pxPerM),
|
||||
y: Math.min(Math.max(pos.y, O), O + h - o.height * pxPerM),
|
||||
})}
|
||||
onDragMove={(e) =>
|
||||
setObDrag({
|
||||
id: o.id,
|
||||
valid: isValidObstacle(room, items, { ...o, x: e.target.x() / pxPerM, y: e.target.y() / pxPerM }),
|
||||
})
|
||||
}
|
||||
onDragEnd={(e) => {
|
||||
const nx = e.target.x() / pxPerM;
|
||||
const ny = e.target.y() / pxPerM;
|
||||
const ok = isValidObstacle(room, items, { ...o, x: nx, y: ny });
|
||||
e.target.position({ x: (ok ? nx : o.x) * pxPerM, y: (ok ? ny : o.y) * pxPerM });
|
||||
if (ok) onMoveObstacle(o.id, nx, ny);
|
||||
setObDrag(null);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c && !interactive) c.style.cursor = "grab";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{items.map((item) => {
|
||||
const shape = configs[item.type]?.shape ?? "chair";
|
||||
const isResizing = resize?.id === item.id;
|
||||
@@ -110,7 +367,8 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
offsetX={wpx / 2}
|
||||
offsetY={hpx / 2}
|
||||
rotation={item.rotation}
|
||||
draggable={!item.locked}
|
||||
listening={interactive}
|
||||
draggable={interactive && !item.locked}
|
||||
onMouseDown={() => onSelect(item.id)}
|
||||
onTouchStart={() => onSelect(item.id)}
|
||||
// Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
|
||||
@@ -118,8 +376,8 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
const hx = (half.width / 2) * pxPerM;
|
||||
const hy = (half.height / 2) * pxPerM;
|
||||
return {
|
||||
x: Math.min(Math.max(pos.x, hx), w - hx),
|
||||
y: Math.min(Math.max(pos.y, hy), h - hy),
|
||||
x: Math.min(Math.max(pos.x, O + hx), O + w - hx),
|
||||
y: Math.min(Math.max(pos.y, O + hy), O + h - hy),
|
||||
};
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
@@ -155,7 +413,7 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
{items.map((item) => {
|
||||
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
||||
const isScreen = configs[item.type]?.shape === "screen";
|
||||
if (item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
|
||||
if (!interactive || item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
|
||||
const r: RectM = resize?.id === item.id ? resize.rect : item;
|
||||
const mid = {
|
||||
left: [r.x, r.y + r.height / 2],
|
||||
@@ -188,7 +446,7 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
const p = e.target.getStage()?.getPointerPosition();
|
||||
const start = resizeStart.current;
|
||||
if (!p || !start) return;
|
||||
const next = resizeLength(start, edge, p.x / pxPerM, room);
|
||||
const next = resizeLength(start, edge, (p.x - O) / pxPerM, room);
|
||||
setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
@@ -207,6 +465,16 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
/>
|
||||
));
|
||||
})}
|
||||
{!interactive &&
|
||||
room.doors.map((d) => {
|
||||
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? wallDrag.offset : d.offset;
|
||||
return wallHit("door", d.id, d.wall, live, d.width, doorSwingRect(room, { ...d, offset: live }));
|
||||
})}
|
||||
{!interactive &&
|
||||
room.screens.map((sc) => {
|
||||
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? wallDrag.offset : sc.offset;
|
||||
return wallHit("screen", sc.id, sc.wall, live, sc.width, wallRect(room, sc.wall, live, sc.width, SCREEN_DEPTH_M));
|
||||
})}
|
||||
{preview && (
|
||||
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
||||
<FurnitureShape
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Door, Obstacle, Room, RoomScreen } from "../types";
|
||||
import { DOOR_WIDTH, MAX_SCREENS, OBSTACLE_SIZE, ROOM_SIZE, SCREEN_WIDTH } from "../geometry";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
|
||||
|
||||
const fmt = (v: number) => v.toFixed(1);
|
||||
|
||||
function useEscape(onClose: () => void) {
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [onClose]);
|
||||
}
|
||||
|
||||
const dangerBtn: React.CSSProperties = { ...btn, color: "#e03131", borderColor: "#e8a8a8" };
|
||||
|
||||
// ---------------- Room settings (Room mode, nothing selected) ----------------
|
||||
|
||||
interface RoomPanelProps {
|
||||
room: Room;
|
||||
minSize: { width: number; height: number };
|
||||
notice: string;
|
||||
onResize: (size: { width?: number; height?: number }) => void;
|
||||
onAddDoor: () => void;
|
||||
onAddScreen: () => void;
|
||||
onAddObstacle: () => void;
|
||||
onExport: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function RoomPanel({ room, minSize, notice, onResize, onAddDoor, onAddScreen, onAddObstacle, onExport, onImport }: RoomPanelProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const limited = minSize.width > ROOM_SIZE.min || minSize.height > ROOM_SIZE.min;
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<div style={title}>Room</div>
|
||||
<div>
|
||||
<div style={label}>Size (m)</div>
|
||||
<div style={twoCol}>
|
||||
<div>
|
||||
<div style={hint}>Width</div>
|
||||
<Wheel value={room.width} min={minSize.width} max={ROOM_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onResize({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={room.height} min={minSize.height} max={ROOM_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onResize({ height: v })} />
|
||||
</div>
|
||||
</div>
|
||||
{limited && (
|
||||
<div style={hint}>
|
||||
The room can't shrink past what's placed in it (min {fmt(minSize.width)} × {fmt(minSize.height)} m).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<button style={btn} onClick={onAddDoor}>
|
||||
Add door
|
||||
</button>
|
||||
<button style={{ ...btn, opacity: room.screens.length >= MAX_SCREENS ? 0.45 : 1 }} disabled={room.screens.length >= MAX_SCREENS} onClick={onAddScreen}>
|
||||
Add screen
|
||||
</button>
|
||||
{room.screens.length >= MAX_SCREENS && <div style={{ ...hint, marginTop: -4 }}>Maximum of {MAX_SCREENS} screens.</div>}
|
||||
<button style={btn} onClick={onAddObstacle}>
|
||||
Add obstacle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{ ...btn, flex: 1 }} onClick={onExport}>
|
||||
Export room
|
||||
</button>
|
||||
<button style={{ ...btn, flex: 1 }} onClick={() => fileRef.current?.click()}>
|
||||
Import room
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = ""; // allow re-importing the same file
|
||||
if (f) onImport(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={hint}>Furniture isn't included in room.json.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Door ----------------
|
||||
|
||||
interface DoorPanelProps {
|
||||
door: Door;
|
||||
isLast: boolean;
|
||||
notice: string;
|
||||
onPatch: (p: Partial<Door>) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DoorPanel({ door, isLast, notice, onPatch, onDelete, onClose }: DoorPanelProps) {
|
||||
useEscape(onClose);
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>Door</div>
|
||||
<div>
|
||||
<div style={label}>Width (m)</div>
|
||||
<Wheel value={door.width} min={DOOR_WIDTH.min} max={DOOR_WIDTH.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
style={{ ...btn, width: "100%", background: door.isLoading ? "#2b2b2b" : "#fff", color: door.isLoading ? "#fff" : "#222" }}
|
||||
onClick={() => onPatch({ isLoading: !door.isLoading })}
|
||||
>
|
||||
Loading door: {door.isLoading ? "on" : "off"}
|
||||
</button>
|
||||
<div style={hint}>Furniture is carried through a loading door.</div>
|
||||
</div>
|
||||
<div style={hint}>The swing area and 0.6 m inside the opening stay clear of furniture.</div>
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
<div>
|
||||
<button style={{ ...dangerBtn, width: "100%", opacity: isLast ? 0.45 : 1, cursor: isLast ? "not-allowed" : "pointer" }} disabled={isLast} onClick={onDelete}>
|
||||
Delete door
|
||||
</button>
|
||||
{isLast && <div style={hint}>A room needs at least one door.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Wall screen ----------------
|
||||
|
||||
interface ScreenPanelProps {
|
||||
screen: RoomScreen;
|
||||
notice: string;
|
||||
onPatch: (p: Partial<RoomScreen>) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ScreenPanel({ screen, notice, onPatch, onDelete, onClose }: ScreenPanelProps) {
|
||||
useEscape(onClose);
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>Screen</div>
|
||||
<div>
|
||||
<div style={label}>Width (m)</div>
|
||||
<Wheel value={screen.width} min={SCREEN_WIDTH.min} max={SCREEN_WIDTH.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
|
||||
</div>
|
||||
<div style={hint}>Drag it along its wall. A 0.3 m strip in front of the wall stays clear.</div>
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
<button style={dangerBtn} onClick={onDelete}>
|
||||
Delete screen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Obstacle ----------------
|
||||
|
||||
interface ObstaclePanelProps {
|
||||
obstacle: Obstacle;
|
||||
notice: string;
|
||||
onPatch: (p: Partial<Obstacle>) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ObstaclePanel({ obstacle, notice, onPatch, onDelete, onClose }: ObstaclePanelProps) {
|
||||
useEscape(onClose);
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>Obstacle</div>
|
||||
<div>
|
||||
<div style={label}>Size (m)</div>
|
||||
<div style={twoCol}>
|
||||
<div>
|
||||
<div style={hint}>Width</div>
|
||||
<Wheel value={obstacle.width} min={OBSTACLE_SIZE.min} max={OBSTACLE_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={obstacle.height} min={OBSTACLE_SIZE.min} max={OBSTACLE_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ height: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
<button style={dangerBtn} onClick={onDelete}>
|
||||
Delete obstacle
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+193
-4
@@ -1,11 +1,69 @@
|
||||
// Pure geometry/rules. No React or Konva imports so an optimizer can reuse this.
|
||||
import type { FurnitureItem, Rect, Room } from "./types";
|
||||
import type { Door, FurnitureItem, Obstacle, Rect, Room, RoomScreen, Wall } from "./types";
|
||||
|
||||
/** Largest px-per-meter that fits the room inside the given pixel box. */
|
||||
export const fitScale = (room: Room, availW: number, availH: number): number =>
|
||||
Math.max(1, Math.min(availW / room.width, availH / room.height));
|
||||
|
||||
export const DEFAULT_ROOM: Room = { width: 10, height: 8 };
|
||||
export const DEFAULT_DOOR_WIDTH = 1.2;
|
||||
|
||||
export const DEFAULT_ROOM: Room = {
|
||||
width: 10,
|
||||
height: 8,
|
||||
doors: [{ id: "door-1", wall: "bottom", offset: (10 - DEFAULT_DOOR_WIDTH) / 2, width: DEFAULT_DOOR_WIDTH, isLoading: false }],
|
||||
screens: [],
|
||||
obstacles: [],
|
||||
};
|
||||
|
||||
// ---- Room features: sizes, keep-clear zones ----
|
||||
export const ROOM_SIZE = { min: 3, max: 60 };
|
||||
export const DOOR_WIDTH = { min: 0.7, max: 4 };
|
||||
export const SCREEN_WIDTH = { min: 1, max: 12 };
|
||||
export const OBSTACLE_SIZE = { min: 0.2, max: 10 };
|
||||
export const MAX_SCREENS = 2;
|
||||
export const DOOR_STRIP_M = 0.6; // keep-clear strip just inside a door opening
|
||||
export const SCREEN_DEPTH_M = 0.3; // strip a wall screen occupies
|
||||
|
||||
const EPS = 1e-6;
|
||||
export const WALLS: Wall[] = ["top", "bottom", "left", "right"];
|
||||
|
||||
export const wallLength = (room: Room, wall: Wall): number =>
|
||||
wall === "top" || wall === "bottom" ? room.width : room.height;
|
||||
|
||||
/** Rect hugging `wall` on the inside: `length` along the wall from `offset`, `depth` into the room. */
|
||||
export function wallRect(room: Room, wall: Wall, offset: number, length: number, depth: number): Rect {
|
||||
switch (wall) {
|
||||
case "top":
|
||||
return { x: offset, y: 0, width: length, height: depth };
|
||||
case "bottom":
|
||||
return { x: offset, y: room.height - depth, width: length, height: depth };
|
||||
case "left":
|
||||
return { x: 0, y: offset, width: depth, height: length };
|
||||
case "right":
|
||||
return { x: room.width - depth, y: offset, width: depth, height: length };
|
||||
}
|
||||
}
|
||||
|
||||
/** Swing area of a door: the bounding square of its quarter-circle, width x width. */
|
||||
export const doorSwingRect = (room: Room, d: Door): Rect => wallRect(room, d.wall, d.offset, d.width, d.width);
|
||||
|
||||
/** Keep-clear zones of a door: the 0.6 m strip inside the opening and the swing area. */
|
||||
export const doorZones = (room: Room, d: Door): Rect[] => [
|
||||
wallRect(room, d.wall, d.offset, d.width, DOOR_STRIP_M),
|
||||
doorSwingRect(room, d),
|
||||
];
|
||||
|
||||
export const screenStrip = (room: Room, s: RoomScreen): Rect =>
|
||||
wallRect(room, s.wall, s.offset, s.width, SCREEN_DEPTH_M);
|
||||
|
||||
/** Every rectangle furniture can't overlap: door zones, screen strips, obstacles. */
|
||||
export function getBlockedRects(room: Room): Rect[] {
|
||||
return [
|
||||
...room.doors.flatMap((d) => doorZones(room, d)),
|
||||
...room.screens.map((s) => screenStrip(room, s)),
|
||||
...room.obstacles,
|
||||
];
|
||||
}
|
||||
|
||||
/** A rect with an optional rotation (degrees) around its center. */
|
||||
export type Box = Rect & { rotation?: number };
|
||||
@@ -63,7 +121,11 @@ export function isValidPlacement(
|
||||
ignoreId?: string,
|
||||
): boolean {
|
||||
const cb = boundsOf(c);
|
||||
return isInsideRoom(cb, room) && !items.some((o) => o.id !== ignoreId && overlaps(cb, boundsOf(o)));
|
||||
return (
|
||||
isInsideRoom(cb, room) &&
|
||||
!items.some((o) => o.id !== ignoreId && overlaps(cb, boundsOf(o))) &&
|
||||
!getBlockedRects(room).some((b) => overlaps(cb, b))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +147,134 @@ export function tryUpdate(
|
||||
return isValidPlacement(next, items, room, item.id) ? next : null;
|
||||
}
|
||||
|
||||
// ---- Screens: drag an end handle to change length (only while unrotated) ----
|
||||
// ---- Room feature rules (pure; the optimizer reuses these) ----
|
||||
|
||||
export interface WallSpec {
|
||||
id: string;
|
||||
kind: "door" | "screen";
|
||||
wall: Wall;
|
||||
offset: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const zonesOf = (room: Room, s: WallSpec): Rect[] =>
|
||||
s.kind === "door"
|
||||
? doorZones(room, { id: s.id, wall: s.wall, offset: s.offset, width: s.width, isLoading: false })
|
||||
: [wallRect(room, s.wall, s.offset, s.width, SCREEN_DEPTH_M)];
|
||||
|
||||
/**
|
||||
* A door/screen is valid if it fits on its wall, doesn't share wall length with another door/screen,
|
||||
* and its keep-clear zones stay in the room and off furniture and obstacles.
|
||||
*/
|
||||
export function isValidWallItem(room: Room, items: FurnitureItem[], s: WallSpec): boolean {
|
||||
if (s.offset < -EPS || s.offset + s.width > wallLength(room, s.wall) + EPS) return false;
|
||||
const others = [
|
||||
...room.doors.filter((d) => d.id !== s.id),
|
||||
...room.screens.filter((o) => o.id !== s.id),
|
||||
];
|
||||
const clash = others.some(
|
||||
(o) => o.wall === s.wall && s.offset < o.offset + o.width - EPS && s.offset + s.width > o.offset + EPS,
|
||||
);
|
||||
if (clash) return false;
|
||||
const bounds = items.map(boundsOf);
|
||||
return zonesOf(room, s).every(
|
||||
(z) =>
|
||||
isInsideRoom(z, room) &&
|
||||
!bounds.some((b) => overlaps(z, b)) &&
|
||||
!room.obstacles.some((o) => overlaps(z, o)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Obstacles stay inside the room and off furniture, other obstacles, and door/screen zones. */
|
||||
export function isValidObstacle(room: Room, items: FurnitureItem[], o: Obstacle): boolean {
|
||||
if (!isInsideRoom(o, room)) return false;
|
||||
if (items.some((i) => overlaps(o, boundsOf(i)))) return false;
|
||||
if (room.obstacles.some((p) => p.id !== o.id && overlaps(o, p))) return false;
|
||||
const zones = [
|
||||
...room.doors.flatMap((d) => doorZones(room, d)),
|
||||
...room.screens.map((s) => screenStrip(room, s)),
|
||||
];
|
||||
return !zones.some((z) => overlaps(o, z));
|
||||
}
|
||||
|
||||
/** Whole-layout check: used when the room size changes or a room is imported. */
|
||||
export function isLayoutValid(room: Room, items: FurnitureItem[]): boolean {
|
||||
if (room.doors.length < 1 || room.screens.length > MAX_SCREENS) return false;
|
||||
const wall = [
|
||||
...room.doors.map((d): WallSpec => ({ ...d, kind: "door" })),
|
||||
...room.screens.map((s): WallSpec => ({ ...s, kind: "screen" })),
|
||||
];
|
||||
return (
|
||||
wall.every((s) => isValidWallItem(room, items, s)) &&
|
||||
room.obstacles.every((o) => isValidObstacle(room, items, o)) &&
|
||||
items.every((i) => isValidPlacement(i, items, room, i.id))
|
||||
);
|
||||
}
|
||||
|
||||
const ceil1 = (v: number): number => Math.ceil(v * 10 - 1e-6) / 10;
|
||||
|
||||
/** Smallest room that still holds the placed furniture, obstacles and wall items (bounding box). */
|
||||
export function minRoomSize(room: Room, items: FurnitureItem[]): { width: number; height: number } {
|
||||
let w: number = ROOM_SIZE.min;
|
||||
let h: number = ROOM_SIZE.min;
|
||||
const grow = (r: Rect) => {
|
||||
w = Math.max(w, r.x + r.width);
|
||||
h = Math.max(h, r.y + r.height);
|
||||
};
|
||||
items.map(boundsOf).forEach(grow);
|
||||
room.obstacles.forEach(grow);
|
||||
const along = (s: { wall: Wall; offset: number; width: number }) => {
|
||||
if (s.wall === "top" || s.wall === "bottom") w = Math.max(w, s.offset + s.width);
|
||||
else h = Math.max(h, s.offset + s.width);
|
||||
};
|
||||
room.screens.forEach(along);
|
||||
for (const d of room.doors) {
|
||||
along(d);
|
||||
if (d.wall === "top" || d.wall === "bottom") h = Math.max(h, d.width); // swing depth
|
||||
else w = Math.max(w, d.width);
|
||||
}
|
||||
return { width: ceil1(w), height: ceil1(h) };
|
||||
}
|
||||
|
||||
/** Resize the room (top-left stays put). Returns null if anything placed wouldn't fit. */
|
||||
export function tryResizeRoom(room: Room, items: FurnitureItem[], size: { width?: number; height?: number }): Room | null {
|
||||
const next = { ...room, ...size };
|
||||
if (next.width < ROOM_SIZE.min - EPS || next.height < ROOM_SIZE.min - EPS) return null;
|
||||
return isLayoutValid(next, items) ? next : null;
|
||||
}
|
||||
|
||||
/** Closest free offset on `wall` to the wall's center, or null if the wall has no room. */
|
||||
export function findWallSpot(room: Room, items: FurnitureItem[], kind: "door" | "screen", wall: Wall, width: number): number | null {
|
||||
const len = wallLength(room, wall);
|
||||
const center = (len - width) / 2;
|
||||
for (let k = 0; k * 0.1 <= len; k++) {
|
||||
for (const sign of k === 0 ? [1] : [1, -1]) {
|
||||
const offset = Math.round((center + sign * k * 0.1) * 100) / 100;
|
||||
if (isValidWallItem(room, items, { id: "__new", kind, wall, offset, width })) return offset;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Free top-left for a new obstacle, nearest the room center, or null if the room is full. */
|
||||
export function findObstacleSpot(room: Room, items: FurnitureItem[], width: number, height: number): { x: number; y: number } | null {
|
||||
const cx = (room.width - width) / 2;
|
||||
const cy = (room.height - height) / 2;
|
||||
const step = 0.25;
|
||||
const cands: { x: number; y: number; d: number }[] = [];
|
||||
for (let x = 0; x <= room.width - width + EPS; x += step) {
|
||||
for (let y = 0; y <= room.height - height + EPS; y += step) cands.push({ x, y, d: Math.hypot(x - cx, y - cy) });
|
||||
}
|
||||
cands.sort((a, b) => a.d - b.d);
|
||||
const first = { x: cx, y: cy, width, height, id: "__new" };
|
||||
if (isValidObstacle(room, items, first)) return { x: cx, y: cy };
|
||||
for (const c of cands) {
|
||||
if (isValidObstacle(room, items, { id: "__new", x: c.x, y: c.y, width, height })) return { x: c.x, y: c.y };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Screens (furniture type): drag an end handle to change length (only while unrotated) ----
|
||||
|
||||
export const MIN_SCREEN_LENGTH = 0.5;
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Validation for room.json import/export. No dependencies; pure.
|
||||
import { DOOR_WIDTH, MAX_SCREENS, OBSTACLE_SIZE, ROOM_SIZE, SCREEN_WIDTH, WALLS, isLayoutValid } from "./geometry";
|
||||
import type { Door, Obstacle, Room, RoomScreen, Wall } from "./types";
|
||||
|
||||
export type ParseResult = { ok: true; room: Room } | { ok: false; error: string };
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
const inRange = (v: number, r: { min: number; max: number }) => v >= r.min - 1e-6 && v <= r.max + 1e-6;
|
||||
const round1 = (v: number) => Math.round(v * 10) / 10;
|
||||
|
||||
export function exportRoom(room: Room): string {
|
||||
const { width, height, doors, screens, obstacles } = room;
|
||||
return JSON.stringify({ width, height, doors, screens, obstacles }, null, 2);
|
||||
}
|
||||
|
||||
export function parseRoom(text: string): ParseResult {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: "Not valid JSON." };
|
||||
}
|
||||
if (!isObj(raw)) return { ok: false, error: "Expected a JSON object with width, height, doors, screens and obstacles." };
|
||||
|
||||
const fail = (error: string): ParseResult => ({ ok: false, error });
|
||||
if (!isNum(raw.width) || !isNum(raw.height)) return fail("width and height must be numbers.");
|
||||
if (!inRange(raw.width, ROOM_SIZE) || !inRange(raw.height, ROOM_SIZE))
|
||||
return fail(`Room width and height must be between ${ROOM_SIZE.min} and ${ROOM_SIZE.max} m.`);
|
||||
if (!Array.isArray(raw.doors) || !Array.isArray(raw.screens) || !Array.isArray(raw.obstacles))
|
||||
return fail("doors, screens and obstacles must all be arrays.");
|
||||
|
||||
const ids = new Set<string>();
|
||||
const uniqueId = (v: unknown, what: string): string | null => {
|
||||
if (typeof v !== "string" || !v) return `${what}: id must be a non-empty string.`;
|
||||
if (ids.has(v)) return `${what}: duplicate id "${v}".`;
|
||||
ids.add(v);
|
||||
return null;
|
||||
};
|
||||
const isWall = (v: unknown): v is Wall => typeof v === "string" && (WALLS as string[]).includes(v);
|
||||
|
||||
const doors: Door[] = [];
|
||||
for (const [n, d] of raw.doors.entries()) {
|
||||
const what = `Door ${n + 1}`;
|
||||
if (!isObj(d)) return fail(`${what} must be an object.`);
|
||||
const e = uniqueId(d.id, what);
|
||||
if (e) return fail(e);
|
||||
if (!isWall(d.wall)) return fail(`${what}: wall must be top, bottom, left or right.`);
|
||||
if (!isNum(d.offset) || !isNum(d.width)) return fail(`${what}: offset and width must be numbers.`);
|
||||
if (!inRange(d.width, DOOR_WIDTH)) return fail(`${what}: width must be ${DOOR_WIDTH.min}-${DOOR_WIDTH.max} m.`);
|
||||
if (typeof d.isLoading !== "boolean") return fail(`${what}: isLoading must be true or false.`);
|
||||
doors.push({ id: d.id as string, wall: d.wall, offset: d.offset, width: d.width, isLoading: d.isLoading });
|
||||
}
|
||||
if (doors.length < 1) return fail("A room needs at least one door.");
|
||||
|
||||
if (raw.screens.length > MAX_SCREENS) return fail(`At most ${MAX_SCREENS} screens are allowed.`);
|
||||
const screens: RoomScreen[] = [];
|
||||
for (const [n, s] of raw.screens.entries()) {
|
||||
const what = `Screen ${n + 1}`;
|
||||
if (!isObj(s)) return fail(`${what} must be an object.`);
|
||||
const e = uniqueId(s.id, what);
|
||||
if (e) return fail(e);
|
||||
if (!isWall(s.wall)) return fail(`${what}: wall must be top, bottom, left or right.`);
|
||||
if (!isNum(s.offset) || !isNum(s.width)) return fail(`${what}: offset and width must be numbers.`);
|
||||
if (!inRange(s.width, SCREEN_WIDTH)) return fail(`${what}: width must be ${SCREEN_WIDTH.min}-${SCREEN_WIDTH.max} m.`);
|
||||
screens.push({ id: s.id as string, wall: s.wall, offset: s.offset, width: s.width });
|
||||
}
|
||||
|
||||
const obstacles: Obstacle[] = [];
|
||||
for (const [n, o] of raw.obstacles.entries()) {
|
||||
const what = `Obstacle ${n + 1}`;
|
||||
if (!isObj(o)) return fail(`${what} must be an object.`);
|
||||
const e = uniqueId(o.id, what);
|
||||
if (e) return fail(e);
|
||||
if (!isNum(o.x) || !isNum(o.y) || !isNum(o.width) || !isNum(o.height)) return fail(`${what}: x, y, width and height must be numbers.`);
|
||||
if (!inRange(o.width, OBSTACLE_SIZE) || !inRange(o.height, OBSTACLE_SIZE))
|
||||
return fail(`${what}: size must be ${OBSTACLE_SIZE.min}-${OBSTACLE_SIZE.max} m.`);
|
||||
obstacles.push({ id: o.id as string, x: o.x, y: o.y, width: o.width, height: o.height });
|
||||
}
|
||||
|
||||
const room: Room = { width: round1(raw.width), height: round1(raw.height), doors, screens, obstacles };
|
||||
if (!isLayoutValid(room, [])) return fail("The doors, screens and obstacles don't fit together (overlap or outside the room).");
|
||||
return { ok: true, room };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type Action =
|
||||
| { kind: "move"; id: string; x: number; y: number }
|
||||
| { kind: "update"; id: string; patch: Partial<FurnitureItem> }
|
||||
| { kind: "remove"; id: string }
|
||||
| { kind: "setRoom"; room: Room; clearItems?: boolean }
|
||||
| { kind: "setAvailable"; type: FurnitureType; value: number }
|
||||
| { kind: "setSeats"; type: FurnitureType; value: number }
|
||||
| { kind: "addConfig"; config: FurnitureTypeConfig };
|
||||
@@ -57,6 +58,8 @@ export function reducer(state: LayoutState, action: Action): LayoutState {
|
||||
...state,
|
||||
items: state.items.map((i) => (i.id === action.id ? { ...i, ...action.patch } : i)),
|
||||
};
|
||||
case "setRoom":
|
||||
return { ...state, room: action.room, items: action.clearItems ? [] : state.items };
|
||||
case "remove":
|
||||
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
|
||||
case "setAvailable":
|
||||
|
||||
@@ -16,9 +16,42 @@ export interface FurnitureItem {
|
||||
seatsOverride?: number; // tables only: overrides the type-level seatsPerUnit
|
||||
}
|
||||
|
||||
export type Wall = "top" | "bottom" | "left" | "right";
|
||||
|
||||
/**
|
||||
* offset = meters from the wall's start to the near edge. Top/bottom walls start at the left,
|
||||
* left/right walls start at the top.
|
||||
*/
|
||||
export interface Door {
|
||||
id: string;
|
||||
wall: Wall;
|
||||
offset: number;
|
||||
width: number;
|
||||
isLoading: boolean; // the door furniture is carried through (used by the optimizer later)
|
||||
}
|
||||
|
||||
/** A screen / stage on a wall (not the "screen" furniture type). */
|
||||
export interface RoomScreen {
|
||||
id: string;
|
||||
wall: Wall;
|
||||
offset: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface Obstacle {
|
||||
id: string;
|
||||
x: number; // meters, top-left, room coordinates
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
width: number; // meters
|
||||
height: number;
|
||||
doors: Door[];
|
||||
screens: RoomScreen[];
|
||||
obstacles: Obstacle[];
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
|
||||
Reference in New Issue
Block a user