This commit is contained in:
Andras Schmelczer 2026-09-11 16:09:28 +01:00
commit d8f2407ddf
44 changed files with 10377 additions and 0 deletions

View file

@ -0,0 +1,364 @@
import { Plus, Trash2, DoorOpen, Check, MoveUpRight } 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, Issues } from './Fields';
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 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 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 (
<>
<div className="editor-heading">
<h2>Room measurements</h2>
<span>Millimetres</span>
</div>
<label className="field">
<span>Room name</span>
<input
value={room.name}
maxLength={80}
onChange={(e) => changeRoom((r) => ({ ...r, name: e.target.value }))}
/>
</label>
<div className="template-options">
<button
className={isRectangle ? 'template active' : 'template'}
aria-pressed={isRectangle}
onClick={() => changeRoom((r) => rectangle(r.id, r.name))}
>
<span className="rectangle-icon" />
Rectangle
</button>
<button
className={room.walls.length === 8 ? 'template active' : 'template'}
aria-pressed={room.walls.length === 8}
onClick={() => changeRoom((r) => fireplace(r.id, r.name))}
>
<span className="fireplace-icon" />
With fireplace
</button>
<button
className="template"
onClick={() =>
changeRoom((r) => ({
...r,
walls: [{ id: `${r.id}-w1`, length: 4000, turn: 90, doors: [] }],
}))
}
>
<MoveUpRight size={19} />
Custom
</button>
</div>
{isRectangle && (
<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={!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" key={wall.id}>
<span className="wall-code">W{i + 1}</span>
<input
aria-label={`Wall ${i + 1} length`}
type="number"
inputMode="decimal"
min={1}
step={1}
value={wall.length}
onChange={(e) =>
changeWall(wall.id, (w) => ({ ...w, length: Number(e.target.value) }))
}
/>
<input
aria-label={`Wall ${i + 1} turn`}
type="number"
inputMode="decimal"
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={(e) =>
changeWall(wall.id, (w) => ({ ...w, turn: Number(e.target.value) }))
}
/>
<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>
<button className="text-button" onClick={addWall} disabled={room.walls.length >= 100}>
<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 >= 100 || Math.abs(geometry.closingTurn) >= 179.99}
>
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">
Width includes trim where skirting stops. Offset is measured from the wall’s start.
</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>
<div className="field-grid">
<NumberField
label="Door offset"
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 >= 30}
>
<Plus size={15} /> Add a doorway
</button>
<div className="panel-divider" />
<NumberField
label="Measurement tolerance"
value={tolerance}
max={50}
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>
)}
</>
);
}