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:
2026-10-17 12:53:40 -04:00
parent d98ce8ddb7
commit 7ec02ed9ed
10 changed files with 1050 additions and 39 deletions
+19 -7
View File
@@ -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
View File
@@ -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}
+25
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
View File
@@ -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
+209
View File
@@ -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
View File
@@ -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;
+84
View File
@@ -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 };
}
+3
View File
@@ -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":
+33
View File
@@ -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 {