488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|