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
+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