# RoomDrawer

**When to use:** An interactive floor plan editor. Draw walls and rooms, cut doors and windows into them, and read the result back as data. Use it for venue layouts, apartment plans, seating maps, or anywhere a user needs to sketch a space and your app needs the geometry afterwards.

**Keywords:** blueprint, cad, sketch, grundriss, apartment, house, layout designer, interior, space planner, architect

**Import:** `import { RoomDrawer } from '@ahrowe/ui'`
**Types:** `import type { RoomDrawerProps, RoomDrawerHandle, RoomDrawerTool } from '@ahrowe/ui'`

The plan model is shared with `RoomViewer` and documented separately in [FloorPlan.md](FloorPlan.md). The one thing to know up front: **a wall between two rooms is a single wall**, and rooms are the enclosed spaces the walls form rather than shapes you draw. Draw two rooms flush against each other and you get seven walls, not eight, so a door cut into the boundary opens both rooms at once.

```tsx
import { useState } from 'react';
import { RoomDrawer, emptyFloorPlan } from '@ahrowe/ui';
import type { FloorPlan } from '@ahrowe/ui';

// Controlled
const [plan, setPlan] = useState<FloorPlan>(() => emptyFloorPlan());
<RoomDrawer value={plan} onChange={setPlan} />

// Uncontrolled, reacting only to committed edits
<RoomDrawer defaultValue={emptyFloorPlan()} onChangeEnd={(plan) => save(plan)} />
```

## Tools

| Tool | Key | What it does |
|------|-----|--------------|
| `select` | `V` | Click to select, drag to edit |
| `wall` | `W` | Click to place corners, building a chain of single walls |
| `rect` | `R` | Drag out a rectangular room in one gesture |
| `door` | `D` | Click a wall to cut a door into it |
| `window` | `N` | Click a wall to cut a window |
| `archway` | `A` | Click a wall to cut an opening with no leaf |

```tsx
// Restrict the toolbar, e.g. a kiosk that only places doors on an existing plan
<RoomDrawer value={plan} onChange={setPlan} tools={['select', 'door']} />
```

**There is no "room" tool, and no room drawing mode.** Closing a shape with `rect` or `wall` produces a room by itself, because a room *is* an enclosed face. A mode that declared which walls belonged to which room would reintroduce the ownership relation that stops a door from cutting a shared wall in the first place. Free-standing interior walls work for the same reason: a wall with a loose end does not need to belong to anything.

**Dragging with `select`:**

- a corner reshapes the rooms that meet there, and dropping it onto another corner welds them
- a wall pushes it sideways along its own normal, **and the room on the other side moves with it** (that is what shared walls mean)
- an opening slides along its wall
- a room moves every corner it owns

**Hold Ctrl/Cmd while dragging a wall to detach it instead.** The wall comes off the
corners it shares, the neighbours keep their ends, and a connecting wall grows between
each old corner and the new one. Use it when a partition joins the wall being moved: a
plain drag takes the partition's end along with it, a detach drag leaves it where it is.

Pulling a wall off a plain corner is deliberately a no-op on the drawing: the connector
continues the wall that was already there, so the seam it leaves is straight and is
dissolved on the spot. Nothing is detached until the wall has moved at least one grid
step, so a Ctrl-click on a wall is still just a selection toggle. Push the wall back onto
the line it came off and the corners weld back together, whether that happens in the same
drag or a later one — the corner that survives is the one that was already there.
Grabbing a selected wall's midpoint handle always adds a corner, Ctrl or not.

## Measuring without drawing

The `measure` tool is a ruler. Drag it across the plan and it reports length and angle, snapping to corners, walls and the grid like everything else, with `Shift` for a fixed angle.

**Measurements are not part of the plan.** They are not walls, they create no rooms, they never reach `onChange`, a downloaded file or the undo stack. That is deliberate rather than a shortcut: a line in the wall graph would close faces, and faces become rooms, so a "guide" stored as geometry would fight the derived-room model. If you need reference geometry that persists, draw a wall with `WallType.Virtual` instead — that *is* part of the plan and does divide space.

They **stay on screen after you let go, and through a change of tool**, because measuring is something you do before drawing rather than instead of it.

While the ruler is the active tool, measurements are editable: drag either **end** to adjust one, drag the **line itself** to slide the whole thing, and `Delete` removes the one under the pointer. `Escape` clears them all, as does `handle.clearMeasurements()`. Under any other tool they are inert scenery, so their handles never compete with the plan's own — and `Delete` while the ruler is up only ever touches measurements, never the plan.

## Selecting several things

Two gestures, because they suit different jobs:

- **Ctrl/Cmd-click** adds or removes one thing at a time, leaving the rest of the selection alone. Precise, and works on rooms, walls, corners and openings alike.
- **Drag a marquee** across empty canvas with the select tool. Bulk, and it catches **corners** — a room is not a primitive you can enclose, it is whatever its corners form, and selecting the corners is what makes a drag move it. Hold Ctrl/Cmd while dragging to add to the selection rather than replace it.

`Ctrl/Cmd+A` selects every room, or every corner in a plan with no enclosed rooms yet. A plain click on empty canvas clears; a Ctrl-click on empty canvas does not, so a near-miss never wipes what you had.

**Shift is deliberately not a multi-select modifier here.** It is already the momentary angle-lock override, and overloading it would make two good features fight over the same key.

**Moving a mixed selection translates the union of its corners.** A room is its face's corners, a wall is its two ends, a corner is itself, so a selection holding all three moves as one rigid body without any special cases. Pressing something already selected drags the whole set; pressing anything else selects just it. Arrow keys nudge the whole set, and Delete removes all of it.

Corners you picked explicitly are drawn filled; corners that came along because their wall or room was selected are outlined, so it is always clear what a drag would actually move. Once more than one thing is selected the inspector shows a count rather than per-entity fields, since a name or thickness box would otherwise be silently editing whichever entry happened to come first.

Set `multiSelect={false}` to keep the drawer single-select: Ctrl-click then just selects, the marquee is disabled, and `Ctrl+A` does nothing.

**Adding a corner mid-wall.** Selecting a wall puts a hollow dot at its midpoint. Dragging that dot splits the wall in two and places the new corner in one gesture, which is how a straight wall becomes an L. Clicking it without dragging does nothing, so a stray tap never silently adds a corner. The inspector's **Add corner** button does the same thing for keyboard users, splitting at the midpoint and selecting the result so the arrow keys can move it.

A corner added this way divides the wall, not the room: the room count is unchanged until walls actually enclose a new space. Deleting the corner again rejoins the two walls into one, straightening the bend out rather than leaving a hole where it was — see the Delete rules below.

## Snapping and angles

Three latching toggles sit in the toolbar, each with a hotkey:

| Toggle | Key | What it does |
|--------|-----|--------------|
| Snap | `S` | Snap to corners, walls, wall crossings, wall extensions, alignments with existing corners, and the grid |
| Angle lock | `L` | Constrain every new or dragged point to a multiple of `constrainAngle` (45° by default) |
| Grid | `G` | Show the grid |

`Shift` and `Alt` are **momentary overrides of whatever is latched**, not one-way switches. `Shift` turns angle lock on while held, or off while held if it is already latched on. `Alt` does the same for snapping, so it doubles as "snap just this once" when snapping is off.

**Angle lock is a hard constraint, not a suggestion.** Once it is on, the point stays on the ray however far the pointer wanders, and a nearby corner can only win if it also lies on that ray. Anything else would mean the lock quietly giving up exactly when you lean on it.

**What it constrains depends on what you are doing.** Drawing a wall, it constrains the wall's own direction from the point it starts at. Dragging a corner, it constrains the **direction of travel from where the corner started**, so all eight directions are reachable from the corner you are holding, the same as Shift-dragging in any design tool.

**Angles are measured from the wall you are continuing as well as from the page.** Drawing a chain, the lock offers multiples of the step from the world axes *and* from the direction of the previous segment, and picks whichever the pointer is nearer. That is what makes it useful on a building that is not aligned to the page: `90°` to the wall you just drew is available even when that wall runs at 37°. The readout marks a relative angle with `rel`. Dragging a corner works the same way, measuring from the other wall meeting there.

**You can see what it is doing.** The construction lines that explain each snap are drawn as you move (`showSnapGuides={false}` turns them off), the snapped point gets a small marker, and the live readout shows length and angle together, highlighted while the lock is holding it.

**What gets a measurement depends on what you selected**, because in each case a different set of walls is the one actually changing:

- a **corner**: every wall meeting it, since reshaping the corner changes all of them and one figure would be ambiguous about which
- a **wall**: that wall, plus the walls running off each of its ends. Dragging a wall translates both its endpoints together, so its own length is precisely the measurement that *cannot* change while the neighbours are the ones being stretched
- a **room being drawn** with the room tool: both sides and the area, each sitting on what it measures. The figures come off the wall centrelines, which is also where the finished room takes its own area from, so the number does not jump the moment you let go
- a **room being dragged**: how far it has moved, plus any wall straddling its boundary. A room drag translates its whole face rigidly, so its own walls and area are all frozen; the offset and the walls bridging to its neighbours are the only things moving. These appear during the drag and clear on release, since an offset has no meaning at rest

Neighbours are drawn dimmer than the wall or corner you are actually moving: they are the consequence of the edit rather than its subject. All of them update live during the drag.

Two details that only matter if you are reasoning about why a drag behaves the way it does. The walls attached to the corner being dragged are excluded from snapping, since they move with it and would otherwise make the corner snap to itself. And the direction angle lock measures from is captured once when the drag starts, taken from a wall whose endpoints do not move, rather than recomputed each frame from the corner in flight.

```tsx
// Start latched to 30° steps, for a non-rectangular building
<RoomDrawer value={plan} onChange={setPlan} angleLock constrainAngle={30} />

// No snapping at all, and no guides
<RoomDrawer value={plan} onChange={setPlan} snap={false} showSnapGuides={false} />
```

The `snap`, `angleLock` and `showGrid` props seed the toggles; changing one resets that latch.

## Keyboard

| Key | Action |
|-----|--------|
| `V` `W` `R` `D` `N` `A` | Switch tool |
| `S` / `L` / `G` | Toggle snapping / angle lock / grid |
| `Ctrl/Cmd+A` | Select everything |
| `Ctrl/Cmd`-click | Add or remove one thing from the selection |
| `Ctrl/Cmd`-drag a wall | Detach it from its corners instead of dragging them along |
| `Shift` (hold) | Momentarily invert angle lock |
| `Alt` (hold) | Momentarily invert snapping |
| `Esc` | Cancel the wall chain, or clear the selection |
| `Enter` | Finish an open wall chain |
| `Delete` / `Backspace` | Delete the selection |
| `Ctrl/Cmd+Z` | Undo |
| `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` | Redo |
| `+` / `-` | Zoom |
| `0` | Fit the plan to the viewport |
| Arrow keys | Nudge the selection by one grid step (`Shift` for ten) |

Keyboard handling is scoped to the drawer's own focused root, so a drawer on the page never swallows `Delete` or `+` from the rest of your app.

**What Delete removes**, which is the one genuinely ambiguous case:

- a corner **between exactly two walls**: the corner only. The two walls rejoin into one and the rejoined wall is selected, so deleting a bend straightens it out instead of punching a hole in the plan. Any openings on the two walls move onto the merged wall, rescaled to fit its new length.
- a corner at a **loose end or a junction of three or more walls**: the walls meeting there, since there is nothing to rejoin them into
- a wall: the wall and its openings
- an opening: just the opening, and the wall heals
- a room: its name, and **only the walls no other room is using**. A wall the room next door still needs stays.

## Pointer and touch

Built on Pointer Events, so mouse, touch and pen behave the same. Pinch to zoom, two-finger or middle-button drag to pan, or hold space and drag. Scroll wheel zooms at the cursor. Hit tolerances are measured in screen pixels and converted through the current zoom, so a corner is just as easy to grab at any zoom level, and they widen automatically on a coarse (touch) pointer.

## Controlled, uncontrolled, and the two change callbacks

`onChange` fires continuously, including for the intermediate values during a drag. `onChangeEnd` fires once per completed gesture or discrete edit. This mirrors `Slider`'s pair: render from `onChange`, persist from `onChangeEnd`.

```tsx
<RoomDrawer
  value={plan}
  onChange={setPlan}                        // every frame of a drag
  onChangeEnd={(plan) => savePlan(plan)}    // once, when the drag lands
/>
```

Both receive a `meta` argument: `{ reason, committed, targetIds }`. `committed` is `false` for mid-gesture values and `true` for the one that also went onto the undo stack.

## Undo and redo

On by default, in both controlled and uncontrolled mode: a consumer who has to implement undo in order to get undo will not get undo. A drag is **one** entry, not one per pointer move, and consecutive edits of the same kind on the same target (typing a room name, spinning a number) merge rather than flooding the stack.

```tsx
<RoomDrawer value={plan} onChange={setPlan} historyMode="none" />  // you own history
```

One thing worth knowing: when `value` changes from outside the component (a load, a server patch), that counts as one history entry, so a subsequent Undo returns to the plan that existed before it. Call `handle.clearHistory()` after such a write if that is not what you want.

## Imperative handle

```tsx
const ref = useRef<RoomDrawerHandle>(null);
<RoomDrawer ref={ref} value={plan} onChange={setPlan} />

ref.current?.fitToContent();
ref.current?.undo();
ref.current?.select({ type: 'room', id: roomId });
ref.current?.deleteSelection();
```

| Method | Description |
|--------|-------------|
| `getPlan()` / `setPlan(plan, options?)` | Read or replace the plan. `{ resetHistory: true }` drops the undo stack |
| `getFloorId()` / `setFloorId(id)` | The storey being edited |
| `addFloor(options?)` / `duplicateFloor()` | Add an empty storey above, or copy this one. Both switch to the new floor and return its id |
| `removeFloor(id?)` / `renameFloor(id, label)` | Remove (never the last one) or rename a storey |
| `moveFloor(id, toIndex)` / `setFloorHeight(id, mm)` | Reorder a storey, or set its height. Both re-stack the elevations |
| `clear()` | Empty the plan, as one history entry |
| `clearMeasurements()` | Remove every ruler measurement. View state, so it never reaches `onChange` |
| `download(fileName?)` | Write the plan to a JSON file, as the toolbar button does |
| `loadJson(text)` | Load JSON text as a plan, as one undoable entry. Returns a `PlanParseResult` |
| `undo()` / `redo()` / `canUndo()` / `canRedo()` / `clearHistory()` | History |
| `getSelection()` / `select(selection)` | Read or replace the selection. `select` also accepts a single entry or `null` |
| `addToSelection(entries)` / `toggleSelection(entry)` / `selectAll()` | Adjust the selection without replacing it |
| `deleteSelection()` | Delete everything selected |
| `getView()` / `setView(view)` / `zoomBy(factor)` / `fitToContent(options?)` | Viewport |
| `toWorld(clientX, clientY)` / `toScreen(point)` | Coordinate conversion |
| `getSvgElement()` | The live `<svg>`, for measuring or screenshotting |

## Key props

| Prop | Type | Description |
|------|------|-------------|
| `value` / `defaultValue` | `FloorPlan` | Controlled / initial plan |
| `onChange` | `(plan, meta) => void` | Every mutation, including mid-drag |
| `onChangeEnd` | `(plan, meta) => void` | Once per committed edit |
| `floorId` | `string` | Which floor to edit (default: the first) |
| `tool` / `defaultTool` / `onToolChange` | `RoomDrawerTool` | Active tool |
| `tools` | `RoomDrawerTool[]` | Restricts and orders the toolbar |
| `selection` / `defaultSelection` / `onSelectionChange` | `PlanSelection[]` | What is selected. An array: a single selection is a one-element array |
| `multiSelect` | `boolean` | Allow selecting several things at once (default `true`) |
| `onHoverChange` | `(target) => void` | Fires as the pointer moves over entities |
| `view` / `defaultView` / `onViewChange` | `PlanView` | Viewport (`x`, `y`, `zoom` in screen px per mm) |
| `minZoom` / `maxZoom` | `number` | Zoom bounds (default `0.005` / `2`) |
| `fitOnMount` | `boolean` | Fit the plan into view once there is something to fit (default `true`) |
| `gridSize` | `number` | Grid spacing in millimetres (default `100`) |
| `showGrid` | `boolean` | Default `true` |
| `snap` | `boolean` | Seeds the snap toggle (default `true`); `Alt` inverts it while held |
| `angleLock` | `boolean` | Seeds the angle lock toggle (default `false`); `Shift` inverts it while held |
| `constrainAngle` | `number` | Angle step in degrees that the lock snaps to (default `45`) |
| `showSnapGuides` | `boolean` | Draw the construction lines explaining each snap (default `true`) |
| `wallThickness` / `wallHeight` | `number` | Millimetres, applied to newly drawn walls |
| `openingDefaults` | `Partial<Record<OpeningKind, { width?, sill?, head? }>>` | Size of newly placed openings |
| `showMeasurements` | `boolean` | Live length, angle and area readouts while drawing (default `true`) |
| `formatLength` / `formatArea` / `formatAngle` / `formatZoom` | `(value) => string` | Override the readouts: millimetres, mm², degrees from -180 to 180, and zoom in screen px per mm. See [Localisation](#localisation) |
| `showToolbar` / `showInspector` / `showHint` / `showHistoryControls` / `showFileControls` / `showFloorBar` | `boolean` | Hide parts of the chrome |
| `floorId` / `defaultFloorId` / `onFloorChange` | `string` / `string` / `(id) => void` | Which storey is being edited |
| `showUnderlay` | `boolean` | Trace the storey below behind this one (default `true`) |
| `fileName` | `string` | Name for the downloaded file; `.json` is appended if missing. Defaults to the plan's `name`, else `floor-plan.json` |
| `onImport` | `(result: PlanParseResult) => void` | Fires after every file opened, whether it worked or not |
| `historyMode` | `'internal' \| 'none'` | Default `'internal'` |
| `historyLimit` | `number` | Maximum undo depth (default `50`) |
| `readOnly` | `boolean` | View and select, but no edits |
| `disabled` | `boolean` | No interaction at all |
| `canvasHeight` | `string` | Any CSS length (default `'420px'`) |
| `getRoomColor` / `renderRoomLabel` | | Per-room fill colour and label content |
| `labels` / `icons` | `RoomDrawerLabels` / `RoomDrawerIcons` | Localise or re-icon the chrome, and name new rooms and floors |

`RoomDrawerProps` extends the full native `div` attribute set plus `data-*`, so `id`, `title`, `aria-*` and the rest pass through to the root.

## Floors

A plan holds a stack of storeys, and the drawer edits one at a time. The strip above the canvas lists them **top first**, the way a building reads in section, with `+` to add an empty storey above the current one.

```tsx
<RoomDrawer defaultFloorId={groundId} onFloorChange={setFloorId} />  {/* uncontrolled */}
<RoomDrawer floorId={floorId} onFloorChange={setFloorId} />          {/* controlled */}
<RoomDrawer showFloorBar={false} />                                  {/* drive it yourself */}
```

With nothing selected, the selection bar becomes the floor's own: rename it, set its **storey height**, move it **up or down the stack**, **copy** it, or delete it. Copying is the usual way to start an upper storey, since the walls below are mostly where the walls above go — it duplicates the geometry with fresh ids, so the two storeys share nothing and editing one never reaches into the other.

**The storey below is traced behind the one you are editing**, faint and dashed, so walls line up between floors. It is a reference only: it takes no pointer events and is never part of what you are drawing. The toolbar toggle turns it off, and it only appears when there is a floor underneath.

```tsx
<RoomDrawer showUnderlay={false} />
```

**Elevations re-stack themselves.** Every storey sits on the one below, so inserting a floor lifts the ones above it, removing one drops them, and raising a storey's height pushes everything over it up. The building's own ground level stays put. Nothing in a 2D plan would ever show a floor buried in the one beneath it, which is why this is not left to the caller.

Reordering is how a **basement** gets made, since `+` always adds above: add a storey, then move it to the bottom. It is otherwise for fixing a building whose floors were drawn in the wrong order.

Every floor edit — add, copy, delete, rename, reorder, height — goes through history, so `Ctrl+Z` takes it back like any other edit.

Floors are part of the plan document, so they survive a download and reopen without anything extra.

## The selection bar

A bar under the canvas shows what is selected and what can be done with it: a name for a room, thickness for a wall, width and sill for an opening, and delete for any of them. It is **always there and always the same height**, including when nothing is selected — it used to appear on selection and size itself to its contents, which moved the canvas out from under the pointer by up to 116px as you clicked between a corner and a wall.

Each selection is named on the left (`Room`, `Wall`, `Door`, `Corner`, `3 selected`), which is the whole content of the bar for a corner: a corner has nothing to edit that dragging it does not already do. Delete sits at the far end, apart from the fields, since it is the one action that applies to every kind of selection and the only destructive one. Override any of the names through `labels` (`selectedRoom`, `selectedWall`, `selectedCorner`, `selectedDoor`, `selectedWindow`, `selectedArchway`, `selectedGap`, `selectedCount`), and hide the bar with `showInspector={false}`.

**Sill**, on an opening, is how far above the floor it starts: 0 for a door, around 900 for a window. It changes nothing in the plan, because a plan is a horizontal cut and every opening looks the same from above. It is stored so the same plan can drive a 3D view later without anyone having to supply the heights again — as are `head` (the top of the opening), `PlanWall.height` and `PlanFloor.elevation`.

## Saving and opening files

The toolbar has a download button and an open button. Download writes the plan as JSON; open reads one back. That is the whole round trip, and the JSON is the interchange format: it is what `RoomViewer` takes, and it is stable across versions in a way a rendered SVG is not.

```tsx
<RoomDrawer showFileControls={false} />          {/* build your own UI instead */}
<RoomDrawer fileName="flat-3.json" />            {/* default: the plan's own name */}
<RoomDrawer onImport={(r) => console.log(r.issues)} />
```

**A file off a disk is untrusted, so opening one repairs rather than trusts.** `planFromJson` rebuilds the plan field by field instead of casting what `JSON.parse` returned, so nothing unchecked reaches the canvas, and it reports everything it had to do:

- a wall whose corners are missing, or an opening whose wall is missing, is **dropped** — there is no way to guess where it was meant to go
- a room whose walls have gone is **kept**, because losing geometry is recoverable by redrawing it and losing the name someone typed is not. Re-adding the wall re-matches the room by itself
- a thickness of `"250"` is read as `250`, and an unrecognised wall type or opening kind falls back to a known one
- a file is only **refused** outright when it is not JSON, is not an object, has no floors, or carries a format version this build does not know. Refusing beats guessing there: a later version may mean something different by the same field names

Anything that had to be repaired appears in the hint line under the canvas, and in `onImport`. A clean file says nothing, because the canvas changing is the report. Opening a file goes through history, so `Ctrl+Z` puts back what was there before it.

To accept a file from your own UI, a drop target or a server, hand the text to `loadJson`:

```tsx
ref.current?.loadJson(await file.text());
```

## Theming

All colours come from theme variables. These control the plan itself, and each falls back to a general theme colour when unset. They are declared on `ThemeVariables`, so a `Theme` gets them with autocomplete:

| Variable | Falls back to |
|----------|---------------|
| `--plan-canvas-background` | `var(--background)` |
| `--plan-grid-color` | `var(--text-color)` at low opacity |
| `--plan-underlay-color` | `var(--text-dark)` at low opacity |
| `--plan-room-fill` | `var(--background-accent)` |
| `--plan-room-fill-hover` | `var(--background-accent-light)` |
| `--plan-room-edge-selected` / `--plan-room-edge-selected-fill` | `var(--primary-color)` / `var(--primary-lighter)` |
| `--plan-wall-fill` | `var(--text-color)` mixed 58% into the canvas |
| `--plan-wall-outline` | `var(--text-color)` mixed 38% into the canvas |
| `--plan-wall-join` | `round` (see below) |
| `--plan-wall-miter-limit` | `4` (see below) |
| `--plan-door-color` | `var(--text-dark)` |
| `--plan-window-color` | `var(--info-color)` |
| `--plan-guide-color` | `var(--info-color)` |
| `--plan-measure-color` | `var(--primary-color)` |

**Walls are drawn as mass, not as ink.** Both wall tones are mixed towards the canvas rather than set to the text colour, so a wall reads as a solid body sitting on the floor and its edge as a soft rim rather than a drafted hairline. The pair must stay two distinct values: the outline pass is only visible where the fill pass does not cover it. The jamb faces at an opening take the *body* tone, since at a doorway they are the only thing standing in for the wall.

**`--plan-wall-join`** is the corner treatment, `round` by default: the outer corners of the building come out softened, and an acute corner cannot throw a mitre spike out of the junction at all. Set it to `miter` for the drafting corner, and `--plan-wall-miter-limit` then governs how far that spike may run. The selection highlight always uses whichever join the walls use, because it is drawn over the same geometry and a different join would leave crescents of wall showing round the corners.

**`--plan-wall-miter-limit`** applies when `--plan-wall-join` is `miter`. It is how sharp a corner may get before it is cut flat rather than run to a point. A mitred join extends to `1 / sin(angle / 2)` times the wall thickness, so it grows without bound as the angle closes: at 10 degrees that is eleven times the wall's own thickness, which reads as a spike fired out of the corner. The default of `4` keeps a true mitred point down to about 29 degrees and caps it at twice the wall's own thickness; sharper than that the corner is cut flat instead. Acute rooms bottom out around 45 degrees in practice and anything under 30 is not a room, so the corners that keep their point are the ones a building actually has. Raise it if you are drawing something genuinely needle-shaped and want the point kept. It governs the corners at a junction too, on the rare occasion one needs filling: where three or more walls meet, the corners between them are normally closed already, except when every wall at the node points into the same half-plane.

**Hover tints the room's floor; selection colours the walls that bound it.** They answer different questions, so they get different channels and can be read at the same time without being confused. Hover is fleeting and asks *which room is under the pointer*, where a faint fill is instant and unambiguous — a wall is not, being shared between the rooms on either side of it. Selection is state you then work inside, where a wash over the room would bury its doors and its label.

The selection highlight is built from **the same geometry as the walls themselves** and drawn in the same two passes, so it inherits their corners and their gaps at openings: a door in the boundary stays a doorway rather than being painted over, and the wall keeps a crisp edge instead of turning into a flat slab. Where two selected rooms share a wall it is drawn once, so it never comes out twice as strong as its neighbours. The room fill carries `data-state="selected" | "hovered"` if you want to style either state further.

## Accessibility

Stated plainly: **a free-form drawing canvas cannot be fully operated from a keyboard**, and this one is not. What is keyboard-operable is tool switching, selection, nudging, deletion, undo/redo, zoom, fit, and the whole inspector (renaming a room, retyping a wall thickness, changing an opening's width). Creating geometry needs a pointer; build it in code through `value` instead, using the operations in [FloorPlan.md](FloorPlan.md).

The root is `role="application"` (justified here, since single-letter keys are intercepted), the toolbar is a real `role="toolbar"` of `<button>`s with `aria-pressed`, and room labels render with a halo so they stay legible over any fill colour a consumer sets.

## Localisation

Every string the drawer produces goes through `labels`, and every number it prints through a `format*` prop. Set them once, app-wide: labels merge per key, so an instance that overrides one string keeps the rest.

Some of those strings end up **in the plan**, not only on screen: the names of the rooms and floors the drawer creates. A plan saved before they were translated keeps "Room 1" and "Ground floor" for good, so set them from the start.

```tsx
const number = new Intl.NumberFormat('de-AT', { maximumFractionDigits: 1 });
const percent = new Intl.NumberFormat('de-AT', { style: 'percent', maximumFractionDigits: 1 });

<AhroweProvider
  defaultProps={{
    RoomDrawer: {
      labels: {
        newRoomName: (n) => `Raum ${n}`,
        newFloorName: (n) => `Etage ${n}`,
        copiedFloorName: (name) => `${name} (Kopie)`,
        initialFloorName: 'Erdgeschoss',
        angleLock: (angle) => `Auf ${angle}°-Schritte einrasten`,
        selectedCount: (count) => `${count} ausgewählt`,
        selectedDoor: 'Tür',
        hintSelect: 'Ecke ziehen zum Umformen · …',
        hintSelectSingle: 'Ecke ziehen zum Umformen · …',
      },
      formatLength: (mm) => `${number.format(mm / 1000)} m`,
      formatArea: (mm2) => `${number.format(mm2 / 1e6)} m²`,
      formatAngle: (degrees) => `${number.format(degrees)}°`,
      formatZoom: (zoom) => percent.format(zoom),
    },
  }}
>
  <App />
</AhroweProvider>
```

A label that carries a value is a function of it: `angleLock` gets `constrainAngle`, `selectedCount` the count, `copiedFloorName` the name of the floor being copied, and `newRoomName` and `newFloorName` the next number whose name is not taken yet. `hintSelect` is the hint with `multiSelect` on and `hintSelectSingle` the one with it off. `formatZoom` gets screen pixels per millimetre, which is the fraction `Intl`'s `percent` style expects.

Two texts are not in `labels`. The root's accessible name is the `aria-label` prop (default "Floor plan editor"), which `defaultProps` can set as well. The repair report after opening a file is `planFromJson`'s list of English sentences, shown as it comes; `onImport` receives it too, if you would rather show your own.

## Global defaults

Adopts `ConfigProvider`, e.g. `defaultProps={{ RoomDrawer: { gridSize: 250, showGrid: false } }}`. See [ConfigProvider.md](ConfigProvider.md).

**Slots:** `root` `toolbar` `toolButton` `toolIcon` `viewControls` `viewButton` `zoomLabel` `canvasWrapper` `canvas` `grid` `rooms` `roomFill` `roomLabel` `walls` `wallBody` `openings` `opening` `overlay` `measureLine` `floorBar` `floorTab` `inspector` `inspectorLabel` `inspectorField` `hint`

**Labels:** every string the drawer's own chrome renders, including `tools`, `storeyHeightHint` and `sillHeightHint`, and the names it gives new rooms and floors. Type: `RoomDrawerLabels`.
