import { useRef, useState, type RefObject } from "react"; import { Stage, Layer, Group, Rect, Path, Arc, Line, RegularPolygon } from "react-konva"; import type Konva from "konva"; 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 { shape: Shape; x: number; // meters y: number; width: number; height: number; 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; preview: Preview | null; // new item being dragged in from the palette pxPerM: number; floorRef: RefObject; selectedId: string | null; onSelect: (id: string | null) => void; onMove: (id: string, x: number, y: number) => void; onResize: (id: string, rect: RectM) => void; } interface DragInfo { id: string; valid: boolean; } interface ResizeInfo { id: string; rect: RectM; 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 }) { // 24x24 padlock centered in the item, counter-rotated so it stays upright. const s = Math.min(1, Math.max(0.35, (Math.min(w, h) * 0.7) / 24)); return ( ); } 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; // The group's origin is the item's CENTER (offset puts the rect's center there), so rotation pivots on it. const candidate = (item: FurnitureItem, node: Konva.Node): FurnitureItem => clampToRoom( { ...item, x: node.x() / pxPerM - item.width / 2, y: node.y() / pxPerM - item.height / 2, }, 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 (
{ 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; const r: RectM = isResizing ? resize.rect : item; const outline: Outline = isResizing ? resize.valid ? "valid" : "invalid" : drag?.id === item.id ? drag.valid ? "valid" : "invalid" : selectedId === item.id ? "selected" : "normal"; const half = boundsOf({ ...r, rotation: item.rotation }); const wpx = r.width * pxPerM; const hpx = r.height * pxPerM; return ( onSelect(item.id)} onTouchStart={() => onSelect(item.id)} // Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local). dragBoundFunc={(pos) => { const hx = (half.width / 2) * pxPerM; const hy = (half.height / 2) * pxPerM; return { 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) => { e.target.moveToTop(); }} onDragMove={(e) => { const c = candidate(item, e.target); setDrag({ id: item.id, valid: isValidPlacement(c, items, room, item.id) }); }} onDragEnd={(e) => { const node = e.target; const c = candidate(item, node); const ok = isValidPlacement(c, items, room, item.id); const final = ok ? c : item; // snap back to last valid position node.position({ x: (final.x + item.width / 2) * pxPerM, y: (final.y + item.height / 2) * pxPerM }); if (ok) onMove(item.id, c.x, c.y); setDrag(null); }} onMouseEnter={(e) => { const c = e.target.getStage()?.container(); if (c) c.style.cursor = item.locked ? "not-allowed" : "grab"; }} onMouseLeave={(e) => { const c = e.target.getStage()?.container(); if (c) c.style.cursor = ""; }} > {item.locked && } ); })} {items.map((item) => { // Screens get length handles on their ends while selected, unlocked and unrotated. const isScreen = configs[item.type]?.shape === "screen"; 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], right: [r.x + r.width, r.y + r.height / 2], } as const; return ENDS.map((edge) => ( { e.cancelBubble = true; resizeStart.current = { x: item.x, y: item.y, width: item.width, height: item.height }; }} onDragMove={(e) => { const p = e.target.getStage()?.getPointerPosition(); const start = resizeStart.current; if (!p || !start) return; 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={() => { if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size setResize(null); resizeStart.current = null; }} onMouseEnter={(e) => { const c = e.target.getStage()?.container(); if (c) c.style.cursor = "ew-resize"; }} onMouseLeave={(e) => { const c = e.target.getStage()?.container(); if (c) c.style.cursor = ""; }} /> )); })} {!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 && ( )}
); }