floor-planner/src/components/OutlineEditor.tsx
2026-09-11 22:14:24 +01:00

488 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useRef, useState } from 'react';
import {
Plus,
Trash2,
DoorOpen,
Check,
MoveUpRight,
GripVertical,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import type { Geometry, Project, Room, Wall } from '../lib/types';
import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format';
import { NumberField, NumericInput, Issues } from './Fields';
import { DoorPosition } from './DoorPosition';
import { insertWallAfter, moveWall } from '../lib/wall-editing';
import { PRECISION, PROJECT_LIMITS } from '../lib/limits';
interface Props {
room: Room;
geometry: Geometry;
tolerance: number;
changeRoom: (fn: (room: Room) => Room) => void;
changeProject: (fn: (project: Project) => Project) => void;
remove: () => void;
canRemove: boolean;
}
export function OutlineEditor({
room,
geometry,
tolerance,
changeRoom,
changeProject,
remove,
canRemove,
}: Props) {
const draggedWall = useRef<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [customSelected, setCustomSelected] = useState(false);
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
const isRectangle =
room.walls.length === 4 &&
room.walls.every((w) => w.turn === 90) &&
room.walls[0].length === room.walls[2].length &&
room.walls[1].length === room.walls[3].length;
const isFireplace =
room.walls.length === 8 &&
room.walls.every((wall, index) => wall.turn === [90, 90, 90, -90, -90, 90, 90, 90][index]);
const isCustom = customSelected || (!isRectangle && !isFireplace);
const lastEdge = geometry.edges.at(-1);
const finalExpectedLength = lastEdge ? Math.hypot(lastEdge.start.x, lastEdge.start.y) : 0;
const addWall = () =>
changeRoom((r) => ({
...r,
walls: [
...r.walls,
{
id: nextId(
`${r.id}-w`,
r.walls.map((w) => w.id),
),
length: 1000,
turn: 90,
doors: [],
},
],
}));
const closeOutline = () =>
changeRoom((r) => ({
...r,
walls: [
...r.walls.map((w, i) =>
i === r.walls.length - 1 ? { ...w, turn: geometry.closingTurn } : w,
),
{
id: nextId(
`${r.id}-w`,
r.walls.map((w) => w.id),
),
length: Math.round(geometry.closingLength * 10) / 10,
turn: 90,
doors: [],
},
],
}));
const addDoor = () => {
const target = room.walls[room.walls.length - 1];
if (!target) return;
changeWall(target.id, (w) => ({
...w,
doors: [
...w.doors,
{
id: nextId(
`${room.id}-door`,
room.walls.flatMap((w) => w.doors.map((d) => d.id)),
),
offset: Math.min(500, Math.max(0, w.length - 826)),
width: 826,
},
],
}));
};
return (
<Fragment key={room.id}>
<div className="editor-heading">
<h2>Room measurements</h2>
</div>
<label className="field">
<span>Room name</span>
<input
value={room.name}
maxLength={PROJECT_LIMITS.nameLength}
onChange={(e) => changeRoom((r) => ({ ...r, name: e.target.value }))}
/>
</label>
<div className="subheading shape-heading">
<h3>Room shape</h3>
</div>
<div
className="template-options"
role="group"
aria-label="Room shape"
aria-describedby="custom-shape-help"
>
<button
className={isRectangle && !isCustom ? 'template active' : 'template'}
aria-pressed={isRectangle && !isCustom}
onClick={() => {
setCustomSelected(false);
changeRoom((r) => rectangle(r.id, r.name));
}}
>
<span className="rectangle-icon" />
Rectangle
</button>
<button
className={isFireplace && !isCustom ? 'template active' : 'template'}
aria-pressed={isFireplace && !isCustom}
onClick={() => {
setCustomSelected(false);
changeRoom((r) => fireplace(r.id, r.name));
}}
>
<span className="fireplace-icon" />
With fireplace
</button>
<button
className={isCustom ? 'template active' : 'template'}
aria-pressed={isCustom}
onClick={() => {
setCustomSelected(true);
changeRoom((r) => ({
...r,
walls: [{ id: `${r.id}-w1`, length: 4000, turn: 90, doors: [] }],
}));
}}
>
<MoveUpRight size={19} />
Custom
</button>
</div>
<p className="shape-help" id="custom-shape-help">
{isCustom && geometry.closed ? (
<>Your outline is complete. Adjust individual walls, corners or doorways below.</>
) : isCustom ? (
<>
Add your walls in order below, then use <strong>Add closing wall</strong> to complete
the outline.
</>
) : (
<>
Have an irregular room? Click <strong>Custom</strong> to start a new outline and enter
each wall and corner. Choosing a shape replaces the current outline and doorways.
</>
)}
</p>
{isRectangle && !isCustom && (
<div className="field-grid quick-dimensions">
<NumberField
label="Room width"
value={room.walls[0].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
walls: r.walls.map((w, i) => (i % 2 === 0 ? { ...w, length: n } : w)),
}))
}
/>
<NumberField
label="Room length"
value={room.walls[1].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
walls: r.walls.map((w, i) => (i % 2 === 1 ? { ...w, length: n } : w)),
}))
}
/>
</div>
)}
<details className="wall-details" open={isCustom || !isRectangle}>
<summary>
Walls & corners <span>{room.walls.length} walls</span>
</summary>
<p className="helper">
Start to the right; work clockwise. Right turn: <b>90°</b>. Left turn: <b>−90°</b>.
</p>
<div className="wall-table">
<div className="wall-head">
<span>Wall</span>
<span>Length · mm</span>
<span>Next turn · °</span>
<span />
</div>
{room.walls.map((wall, i) => (
<div
className={`wall-row${dropTarget === wall.id ? ' drag-over' : ''}`}
key={wall.id}
data-wall-id={wall.id}
onDragOver={(event) => {
if (!draggedWall.current || draggedWall.current === wall.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setDropTarget(wall.id);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
setDropTarget(null);
}}
onDrop={(event) => {
const id = draggedWall.current;
if (!id) return;
event.preventDefault();
changeRoom((r) => moveWall(r, id, i));
draggedWall.current = null;
setDropTarget(null);
}}
>
<span className="wall-code">W{i + 1}</span>
<NumericInput
aria-label={`Wall ${i + 1} length`}
min={1}
step={1}
value={wall.length}
onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
/>
<NumericInput
aria-label={`Wall ${i + 1} turn`}
min={-179}
max={179}
step={0.1}
value={
geometry.closed && i === room.walls.length - 1
? Math.round(normalizeAngle(-(lastEdge?.heading ?? 0)) * 10) / 10
: wall.turn
}
title={
geometry.closed && i === room.walls.length - 1
? 'The final corner is derived from the closed outline.'
: 'Turn after this wall; positive turns right.'
}
disabled={geometry.closed && i === room.walls.length - 1}
onChange={(turn) => changeWall(wall.id, (w) => ({ ...w, turn }))}
/>
<div className="wall-actions">
<button
className="icon-button wall-drag-handle"
draggable
aria-label={`Drag wall ${i + 1} to reorder`}
title="Drag to reorder"
onDragStart={(event) => {
draggedWall.current = wall.id;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', wall.id);
}}
onDragEnd={() => {
draggedWall.current = null;
setDropTarget(null);
}}
>
<GripVertical size={16} />
</button>
<button
className="icon-button"
aria-label={`Move wall ${i + 1} up`}
title="Move wall up"
disabled={i === 0}
onClick={() => changeRoom((r) => moveWall(r, wall.id, i - 1))}
>
<ArrowUp size={16} />
</button>
<button
className="icon-button"
aria-label={`Move wall ${i + 1} down`}
title="Move wall down"
disabled={i === room.walls.length - 1}
onClick={() => changeRoom((r) => moveWall(r, wall.id, i + 1))}
>
<ArrowDown size={16} />
</button>
<button
className="icon-button"
aria-label={`Insert wall after W${i + 1}`}
title={`Insert wall after W${i + 1}`}
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
>
<Plus size={16} />
</button>
<button
className="icon-button"
aria-label={`Remove wall ${i + 1}`}
disabled={room.walls.length === 1}
onClick={() =>
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
<button
className="text-button"
onClick={addWall}
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
>
<Plus size={15} /> Add a wall
</button>
</details>
{geometry.closed ? (
<div className="closure-check">
<Check size={17} />
<div>
<strong>Outline closed</strong>
<span>
Closing gap {mm(geometry.gap)} mm · tolerance {mm(tolerance)} mm
</span>
<span>
Final wall: {mm(finalExpectedLength)} mm expected ·{' '}
{mm(Math.abs(geometry.finalLengthError))} mm difference
</span>
</div>
</div>
) : (
room.walls.length >= 2 &&
geometry.closingLength > 0 && (
<div className="closure-suggestion">
<div>
<strong>{mm(geometry.closingLength)} mm to close</strong>
<p>Next turn {mm(geometry.closingTurn)}°. After adding, enter the measured length.</p>
<button
className="text-button"
onClick={closeOutline}
disabled={
room.walls.length >= PROJECT_LIMITS.wallsPerRoom ||
Math.abs(geometry.closingTurn) >= PROJECT_LIMITS.maxTurn - PRECISION.millimetres
}
>
Add closing wall <Plus size={14} />
</button>
{room.walls.length >= 3 && (
<p>
If W{room.walls.length} is the final wall: {mm(finalExpectedLength)} mm expected;
length differs by {mm(Math.abs(geometry.finalLengthError))} mm, direction by{' '}
{mm(Math.abs(geometry.finalAngleError))}°.
</p>
)}
</div>
</div>
)
)}
<Issues issues={geometry.issues.filter((i) => i.severity !== 'info')} compact />
<div className="panel-divider" />
<div className="subheading">
<h3>
<DoorOpen size={16} /> Doorways
</h3>
<span>{room.walls.reduce((n, w) => n + w.doors.length, 0)} openings</span>
</div>
<p className="helper">Door width includes trim where skirting stops.</p>
{room.walls.flatMap((wall, wallIndex) =>
wall.doors.map((door) => (
<div className="door-card" key={door.id}>
<div className="door-card-title">
<label>
On wall{' '}
<select
aria-label={`Door ${door.id} wall`}
value={wall.id}
onChange={(e) =>
changeRoom((r) => ({
...r,
walls: r.walls.map((w) =>
w.id === wall.id
? { ...w, doors: w.doors.filter((d) => d.id !== door.id) }
: w.id === e.target.value
? { ...w, doors: [...w.doors, door] }
: w,
),
}))
}
>
{room.walls.map((w, i) => (
<option key={w.id} value={w.id}>
W{i + 1}
</option>
))}
</select>
</label>
<button
className="icon-button"
aria-label={`Remove door on wall ${wallIndex + 1}`}
onClick={() =>
changeWall(wall.id, (w) => ({
...w,
doors: w.doors.filter((d) => d.id !== door.id),
}))
}
>
<Trash2 size={14} />
</button>
</div>
<DoorPosition
wall={wall}
door={door}
wallIndex={wallIndex}
wallCount={room.walls.length}
closed={geometry.closed}
/>
<div className="field-grid">
<NumberField
label={`From ${wallIndex === 0 && !geometry.closed ? 'W1 start' : `W${wallIndex === 0 ? room.walls.length : wallIndex} corner`}`}
value={door.offset}
onChange={(n) =>
changeWall(wall.id, (w) => ({
...w,
doors: w.doors.map((d) => (d.id === door.id ? { ...d, offset: n } : d)),
}))
}
/>
<NumberField
label="Door width"
min={1}
value={door.width}
onChange={(n) =>
changeWall(wall.id, (w) => ({
...w,
doors: w.doors.map((d) => (d.id === door.id ? { ...d, width: n } : d)),
}))
}
/>
</div>
</div>
)),
)}
<button
className="text-button"
onClick={addDoor}
disabled={
!room.walls.length || room.walls.at(-1)!.doors.length >= PROJECT_LIMITS.doorsPerWall
}
>
<Plus size={15} /> Add a doorway
</button>
<div className="panel-divider" />
<NumberField
label="Measurement tolerance"
value={tolerance}
max={PROJECT_LIMITS.maxTolerance}
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
hint="Largest closing gap accepted for the cut plan."
/>
{canRemove && (
<button className="text-button danger remove-room" onClick={remove}>
<Trash2 size={14} />
Remove this room
</button>
)}
</Fragment>
);
}