diff --git a/CLAUDE.md b/CLAUDE.md index 9118f17..eeacba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, 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, 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, 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. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 554f1b6..b03fba8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const floorRef = useRef(null); const mainRef = useRef(null); + const roomRef = useRef(state.room); + roomRef.current = state.room; const [pxPerM, setPxPerM] = useState(60); + const [mode, setMode] = useState("layout"); + const [roomSel, setRoomSel] = useState(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) => { + 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) => { + 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) => { + 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 = ( + patchDoor(door.id, p)} onDelete={deleteRoomSel} onClose={deselectRoom} /> + ); + } else if (screen) { + panel = ( + patchScreen(screen.id, p)} onDelete={deleteRoomSel} onClose={deselectRoom} /> + ); + } else if (obstacle) { + panel = ( + patchObstacle(obstacle.id, p)} onDelete={deleteRoomSel} onClose={deselectRoom} /> + ); + } else { + panel = ( + + ); + } + } else if (selectedItem) { panel = ( openSidebar({ kind: "type", id })} onNewCustom={() => openSidebar({ kind: "custom" })} + header={} panel={panel} />
{ + 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} diff --git a/src/components/ModeToggle.tsx b/src/components/ModeToggle.tsx new file mode 100644 index 0000000..6bd6475 --- /dev/null +++ b/src/components/ModeToggle.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/components/Palette.tsx b/src/components/Palette.tsx index 8c3881e..e7e200f 100644 --- a/src/components/Palette.tsx +++ b/src/components/Palette.tsx @@ -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) => ( diff --git a/src/components/Room.tsx b/src/components/Room.tsx index 17508be..2bae964 100644 --- a/src/components/Room.tsx +++ b/src/components/Room.tsx @@ -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; @@ -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): [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 ( + + + + + ); +} + +const ARROW_ROT: Record = { 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 ( + + + + + ); +} 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(null); const [resize, setResize] = useState(null); const resizeStart = useRef(null); + const [wallDrag, setWallDrag] = useState(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 ( + 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 (
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); + }} > - + + + + {/* 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 ; + })} + {[[-O, -O], [w, -O], [-O, h], [w, h]].map(([x, y]) => ( + + ))} + + {/* 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 ; + })} + + {/* wall screens */} + {room.screens.map((sc) => { + const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? { ...sc, offset: wallDrag.offset } : sc; + return ; + })} + + {/* 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 ( + 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 && ( 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(null); + const limited = minSize.width > ROOM_SIZE.min || minSize.height > ROOM_SIZE.min; + return ( +
+
Room
+
+
Size (m)
+
+
+
Width
+ onResize({ width: v })} /> +
+
+
Length
+ onResize({ height: v })} /> +
+
+ {limited && ( +
+ The room can't shrink past what's placed in it (min {fmt(minSize.width)} × {fmt(minSize.height)} m). +
+ )} +
+ +
+ + + {room.screens.length >= MAX_SCREENS &&
Maximum of {MAX_SCREENS} screens.
} + +
+ + {notice &&
{notice}
} + +
+ + + { + const f = e.target.files?.[0]; + e.target.value = ""; // allow re-importing the same file + if (f) onImport(f); + }} + /> +
+
Furniture isn't included in room.json.
+
+ ); +} + +// ---------------- Door ---------------- + +interface DoorPanelProps { + door: Door; + isLast: boolean; + notice: string; + onPatch: (p: Partial) => void; + onDelete: () => void; + onClose: () => void; +} + +export function DoorPanel({ door, isLast, notice, onPatch, onDelete, onClose }: DoorPanelProps) { + useEscape(onClose); + return ( +
+ +
Door
+
+
Width (m)
+ onPatch({ width: v })} /> +
+
+ +
Furniture is carried through a loading door.
+
+
The swing area and 0.6 m inside the opening stay clear of furniture.
+ {notice &&
{notice}
} +
+ + {isLast &&
A room needs at least one door.
} +
+
+ ); +} + +// ---------------- Wall screen ---------------- + +interface ScreenPanelProps { + screen: RoomScreen; + notice: string; + onPatch: (p: Partial) => void; + onDelete: () => void; + onClose: () => void; +} + +export function ScreenPanel({ screen, notice, onPatch, onDelete, onClose }: ScreenPanelProps) { + useEscape(onClose); + return ( +
+ +
Screen
+
+
Width (m)
+ onPatch({ width: v })} /> +
+
Drag it along its wall. A 0.3 m strip in front of the wall stays clear.
+ {notice &&
{notice}
} + +
+ ); +} + +// ---------------- Obstacle ---------------- + +interface ObstaclePanelProps { + obstacle: Obstacle; + notice: string; + onPatch: (p: Partial) => void; + onDelete: () => void; + onClose: () => void; +} + +export function ObstaclePanel({ obstacle, notice, onPatch, onDelete, onClose }: ObstaclePanelProps) { + useEscape(onClose); + return ( +
+ +
Obstacle
+
+
Size (m)
+
+
+
Width
+ onPatch({ width: v })} /> +
+
+
Length
+ onPatch({ height: v })} /> +
+
+
+ {notice &&
{notice}
} + +
+ ); +} diff --git a/src/geometry.ts b/src/geometry.ts index e7b7e93..b70a6b6 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -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; diff --git a/src/roomSchema.ts b/src/roomSchema.ts new file mode 100644 index 0000000..6a058d5 --- /dev/null +++ b/src/roomSchema.ts @@ -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 => 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(); + 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 }; +} diff --git a/src/store.ts b/src/store.ts index effac36..2c7a910 100644 --- a/src/store.ts +++ b/src/store.ts @@ -14,6 +14,7 @@ export type Action = | { kind: "move"; id: string; x: number; y: number } | { kind: "update"; id: string; patch: Partial } | { 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": diff --git a/src/types.ts b/src/types.ts index cf1e434..03199b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 {