wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
364
src/components/OutlineEditor.tsx
Normal file
364
src/components/OutlineEditor.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue