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.
|
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.
|
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).
|
- 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.
|
- 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
|
## Tech stack
|
||||||
|
|
||||||
@@ -48,7 +55,12 @@ interface FurnitureItem {
|
|||||||
seatsOverride?: number; // tables: overrides config.seatsPerUnit
|
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 {
|
interface FurnitureTypeConfig {
|
||||||
id: FurnitureType; name: string; shape: Shape;
|
id: FurnitureType; name: string; shape: Shape;
|
||||||
@@ -62,7 +74,7 @@ interface FurnitureTypeConfig {
|
|||||||
// seats for an item = item.seatsOverride ?? config.seatsPerUnit (effectiveSeats)
|
// 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
|
## Conventions
|
||||||
|
|
||||||
@@ -74,9 +86,9 @@ Store (`store.ts`): `{ room, items, configs: Record<id, config>, typeOrder: id[]
|
|||||||
|
|
||||||
## File layout
|
## 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/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)
|
## Roadmap (not built yet, don't block)
|
||||||
|
|
||||||
@@ -103,6 +115,6 @@ npx tsc --noEmit # type check
|
|||||||
|
|
||||||
## Working agreement
|
## 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.
|
- After changes, make sure the app builds and type checks.
|
||||||
- Hackathon pace: prefer simple and working over clever.
|
- Hackathon pace: prefer simple and working over clever.
|
||||||
+194
-8
@@ -1,17 +1,34 @@
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
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 { 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 { placedCount, remaining } from "./inventory";
|
||||||
import { Palette } from "./components/Palette";
|
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 { ScaleBar } from "./components/ScaleBar";
|
||||||
import { TypeMenu } from "./components/TypeMenu";
|
import { TypeMenu } from "./components/TypeMenu";
|
||||||
import { ItemPanel } from "./components/ItemPanel";
|
import { ItemPanel } from "./components/ItemPanel";
|
||||||
import { CustomForm, type CustomDraft } from "./components/CustomForm";
|
import { CustomForm, type CustomDraft } from "./components/CustomForm";
|
||||||
import { ShapeIcon } from "./components/Icons";
|
import { ShapeIcon } from "./components/Icons";
|
||||||
|
|
||||||
const WALL_PX = 6; // room wall border, must match Room.tsx
|
|
||||||
const SCALE_BAR_PX = 40;
|
const SCALE_BAR_PX = 40;
|
||||||
|
|
||||||
interface PaletteDrag {
|
interface PaletteDrag {
|
||||||
@@ -30,7 +47,12 @@ export default function App() {
|
|||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const floorRef = useRef<HTMLDivElement>(null);
|
const floorRef = useRef<HTMLDivElement>(null);
|
||||||
const mainRef = useRef<HTMLElement>(null);
|
const mainRef = useRef<HTMLElement>(null);
|
||||||
|
const roomRef = useRef(state.room);
|
||||||
|
roomRef.current = state.room;
|
||||||
const [pxPerM, setPxPerM] = useState(60);
|
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 nextItemId = useRef(1);
|
||||||
const nextCustomId = useRef(1);
|
const nextCustomId = useRef(1);
|
||||||
|
|
||||||
@@ -48,6 +70,19 @@ export default function App() {
|
|||||||
const closeSidebar = useCallback(() => setSidebar({ kind: "tiles" }), []);
|
const closeSidebar = useCallback(() => setSidebar({ kind: "tiles" }), []);
|
||||||
const deselect = useCallback(() => setSelectedId(null), []);
|
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(() => {
|
const deleteSelected = useCallback(() => {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
dispatch({ kind: "remove", id: selectedId });
|
dispatch({ kind: "remove", id: selectedId });
|
||||||
@@ -60,13 +95,19 @@ export default function App() {
|
|||||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||||
const t = e.target as HTMLElement | null;
|
const t = e.target as HTMLElement | null;
|
||||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||||
|
if (roomSel) {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteRoomSel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deleteSelected();
|
deleteSelected();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", key);
|
window.addEventListener("keydown", key);
|
||||||
return () => window.removeEventListener("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).
|
// Maximise the room inside the center area (minus wall border and the scale bar).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,11 +115,11 @@ export default function App() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const ro = new ResizeObserver(([entry]) => {
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
const { width, height } = entry.contentRect;
|
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);
|
ro.observe(el);
|
||||||
return () => ro.disconnect();
|
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.
|
// Cursor position -> a new item's box (centered on cursor, clamped), or null if outside the floor.
|
||||||
const toRoomRect = (d: PaletteDrag): Rect | null => {
|
const toRoomRect = (d: PaletteDrag): Rect | null => {
|
||||||
@@ -151,6 +192,116 @@ export default function App() {
|
|||||||
return true;
|
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 saveCustom = (d: CustomDraft) => {
|
||||||
const id = `custom-${nextCustomId.current++}`;
|
const id = `custom-${nextCustomId.current++}`;
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -175,7 +326,33 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let panel = null;
|
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 = (
|
panel = (
|
||||||
<ItemPanel
|
<ItemPanel
|
||||||
key={selectedItem.id}
|
key={selectedItem.id}
|
||||||
@@ -209,6 +386,7 @@ export default function App() {
|
|||||||
onStartDrag={startDrag}
|
onStartDrag={startDrag}
|
||||||
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
|
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
|
||||||
onNewCustom={() => openSidebar({ kind: "custom" })}
|
onNewCustom={() => openSidebar({ kind: "custom" })}
|
||||||
|
header={<ModeToggle mode={mode} onChange={switchMode} />}
|
||||||
panel={panel}
|
panel={panel}
|
||||||
/>
|
/>
|
||||||
<main
|
<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 }}
|
style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", overflow: "hidden", padding: 24 }}
|
||||||
>
|
>
|
||||||
<Room
|
<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}
|
pxPerM={pxPerM}
|
||||||
room={state.room}
|
room={state.room}
|
||||||
items={state.items}
|
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;
|
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
|
||||||
onOpenMenu: (type: FurnitureType) => void;
|
onOpenMenu: (type: FurnitureType) => void;
|
||||||
onNewCustom: () => void;
|
onNewCustom: () => void;
|
||||||
|
header?: ReactNode; // always shown first (mode toggle)
|
||||||
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
|
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 [width, setWidth] = useState(264);
|
||||||
|
|
||||||
const startResize = (e: PointerEvent) => {
|
const startResize = (e: PointerEvent) => {
|
||||||
@@ -158,6 +159,7 @@ export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{header}
|
||||||
{panel ?? (
|
{panel ?? (
|
||||||
<>
|
<>
|
||||||
{types.map((cfg) => (
|
{types.map((cfg) => (
|
||||||
|
|||||||
+287
-19
@@ -1,8 +1,19 @@
|
|||||||
import { useRef, useState, type RefObject } from "react";
|
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 type Konva from "konva";
|
||||||
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
|
import {
|
||||||
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
|
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";
|
import { FurnitureShape, type Outline } from "./FurnitureShape";
|
||||||
|
|
||||||
export interface Preview {
|
export interface Preview {
|
||||||
@@ -14,7 +25,16 @@ export interface Preview {
|
|||||||
valid: boolean;
|
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 {
|
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;
|
room: RoomT;
|
||||||
items: FurnitureItem[];
|
items: FurnitureItem[];
|
||||||
configs: Record<string, FurnitureTypeConfig>;
|
configs: Record<string, FurnitureTypeConfig>;
|
||||||
@@ -38,7 +58,99 @@ interface ResizeInfo {
|
|||||||
valid: boolean;
|
valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WallDrag {
|
||||||
|
kind: "door" | "screen";
|
||||||
|
id: string;
|
||||||
|
offset: number;
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const HANDLE_PX = 10;
|
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"];
|
const ENDS: ("left" | "right")[] = ["left", "right"];
|
||||||
|
|
||||||
function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }) {
|
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 [drag, setDrag] = useState<DragInfo | null>(null);
|
||||||
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
||||||
const resizeStart = useRef<RectM | 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 w = room.width * pxPerM;
|
||||||
const h = room.height * pxPerM;
|
const h = room.height * pxPerM;
|
||||||
|
|
||||||
@@ -70,26 +186,167 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
|||||||
room,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={floorRef}
|
|
||||||
style={{
|
style={{
|
||||||
width: w,
|
width: fw,
|
||||||
height: h,
|
height: fh,
|
||||||
border: "6px solid #2b2b2b",
|
|
||||||
boxSizing: "content-box",
|
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
boxShadow: "0 4px 18px rgba(0,0,0,0.12)",
|
boxShadow: "0 4px 18px rgba(0,0,0,0.12)",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
|
ref={floorRef}
|
||||||
>
|
>
|
||||||
<Stage
|
<Stage
|
||||||
width={w}
|
width={fw}
|
||||||
height={h}
|
height={fh}
|
||||||
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
|
onMouseDown={(e) => {
|
||||||
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
|
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) => {
|
{items.map((item) => {
|
||||||
const shape = configs[item.type]?.shape ?? "chair";
|
const shape = configs[item.type]?.shape ?? "chair";
|
||||||
const isResizing = resize?.id === item.id;
|
const isResizing = resize?.id === item.id;
|
||||||
@@ -110,7 +367,8 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
|||||||
offsetX={wpx / 2}
|
offsetX={wpx / 2}
|
||||||
offsetY={hpx / 2}
|
offsetY={hpx / 2}
|
||||||
rotation={item.rotation}
|
rotation={item.rotation}
|
||||||
draggable={!item.locked}
|
listening={interactive}
|
||||||
|
draggable={interactive && !item.locked}
|
||||||
onMouseDown={() => onSelect(item.id)}
|
onMouseDown={() => onSelect(item.id)}
|
||||||
onTouchStart={() => onSelect(item.id)}
|
onTouchStart={() => onSelect(item.id)}
|
||||||
// Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
|
// 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 hx = (half.width / 2) * pxPerM;
|
||||||
const hy = (half.height / 2) * pxPerM;
|
const hy = (half.height / 2) * pxPerM;
|
||||||
return {
|
return {
|
||||||
x: Math.min(Math.max(pos.x, hx), w - hx),
|
x: Math.min(Math.max(pos.x, O + hx), O + w - hx),
|
||||||
y: Math.min(Math.max(pos.y, hy), h - hy),
|
y: Math.min(Math.max(pos.y, O + hy), O + h - hy),
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
@@ -155,7 +413,7 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
||||||
const isScreen = configs[item.type]?.shape === "screen";
|
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 r: RectM = resize?.id === item.id ? resize.rect : item;
|
||||||
const mid = {
|
const mid = {
|
||||||
left: [r.x, r.y + r.height / 2],
|
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 p = e.target.getStage()?.getPointerPosition();
|
||||||
const start = resizeStart.current;
|
const start = resizeStart.current;
|
||||||
if (!p || !start) return;
|
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) });
|
setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => {
|
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 && (
|
{preview && (
|
||||||
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
||||||
<FurnitureShape
|
<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.
|
// 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. */
|
/** Largest px-per-meter that fits the room inside the given pixel box. */
|
||||||
export const fitScale = (room: Room, availW: number, availH: number): number =>
|
export const fitScale = (room: Room, availW: number, availH: number): number =>
|
||||||
Math.max(1, Math.min(availW / room.width, availH / room.height));
|
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. */
|
/** A rect with an optional rotation (degrees) around its center. */
|
||||||
export type Box = Rect & { rotation?: number };
|
export type Box = Rect & { rotation?: number };
|
||||||
@@ -63,7 +121,11 @@ export function isValidPlacement(
|
|||||||
ignoreId?: string,
|
ignoreId?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const cb = boundsOf(c);
|
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;
|
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;
|
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: "move"; id: string; x: number; y: number }
|
||||||
| { kind: "update"; id: string; patch: Partial<FurnitureItem> }
|
| { kind: "update"; id: string; patch: Partial<FurnitureItem> }
|
||||||
| { kind: "remove"; id: string }
|
| { kind: "remove"; id: string }
|
||||||
|
| { kind: "setRoom"; room: Room; clearItems?: boolean }
|
||||||
| { kind: "setAvailable"; type: FurnitureType; value: number }
|
| { kind: "setAvailable"; type: FurnitureType; value: number }
|
||||||
| { kind: "setSeats"; type: FurnitureType; value: number }
|
| { kind: "setSeats"; type: FurnitureType; value: number }
|
||||||
| { kind: "addConfig"; config: FurnitureTypeConfig };
|
| { kind: "addConfig"; config: FurnitureTypeConfig };
|
||||||
@@ -57,6 +58,8 @@ export function reducer(state: LayoutState, action: Action): LayoutState {
|
|||||||
...state,
|
...state,
|
||||||
items: state.items.map((i) => (i.id === action.id ? { ...i, ...action.patch } : i)),
|
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":
|
case "remove":
|
||||||
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
|
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
|
||||||
case "setAvailable":
|
case "setAvailable":
|
||||||
|
|||||||
@@ -16,9 +16,42 @@ export interface FurnitureItem {
|
|||||||
seatsOverride?: number; // tables only: overrides the type-level seatsPerUnit
|
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 {
|
export interface Room {
|
||||||
width: number; // meters
|
width: number; // meters
|
||||||
height: number;
|
height: number;
|
||||||
|
doors: Door[];
|
||||||
|
screens: RoomScreen[];
|
||||||
|
obstacles: Obstacle[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Rect {
|
export interface Rect {
|
||||||
|
|||||||
Reference in New Issue
Block a user