@ahrowe/ui 0.25.2 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/common/floorPlan/floorPlan.faces.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.faces.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.geometry.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.geometry.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.graph.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.graph.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.hit.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.hit.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.json.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.json.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.path.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.path.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.plan.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.plan.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.rooms.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.rooms.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.snap.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.snap.mjs.map +1 -0
- package/dist/esm/common/floorPlan/floorPlan.types.mjs +2 -0
- package/dist/esm/common/floorPlan/floorPlan.types.mjs.map +1 -0
- package/dist/esm/common/floorPlan/legacySvg.mjs +2 -0
- package/dist/esm/common/floorPlan/legacySvg.mjs.map +1 -0
- package/dist/esm/common/planCanvas/planCanvas.mjs +2 -0
- package/dist/esm/common/planCanvas/planCanvas.mjs.map +1 -0
- package/dist/esm/common/planCanvas/planCanvas.module.mjs +2 -0
- package/dist/esm/common/planCanvas/planCanvas.module.mjs.map +1 -0
- package/dist/esm/common/roomDrawer/roomDrawer.mjs +1 -1
- package/dist/esm/common/roomDrawer/roomDrawer.mjs.map +1 -1
- package/dist/esm/common/roomDrawer/roomDrawer.module.mjs +1 -1
- package/dist/esm/common/roomDrawer/roomDrawer.module.mjs.map +1 -1
- package/dist/esm/common/roomDrawer/usePlanHistory.mjs +2 -0
- package/dist/esm/common/roomDrawer/usePlanHistory.mjs.map +1 -0
- package/dist/esm/common/roomViewer/roomViewer.mjs +1 -1
- package/dist/esm/common/roomViewer/roomViewer.mjs.map +1 -1
- package/dist/esm/common/roomViewer/roomViewer.module.mjs +1 -1
- package/dist/esm/common/roomViewer/roomViewer.module.mjs.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/index.cjs +3 -7
- package/dist/index.cjs.map +1 -1
- package/dist/style.css +1 -1
- package/dist/types/package/common/configProvider/configProvider.types.d.ts +4 -0
- package/dist/types/package/common/floorPlan/floorPlan.faces.d.ts +33 -0
- package/dist/types/package/common/floorPlan/floorPlan.fixtures.d.ts +35 -0
- package/dist/types/package/common/floorPlan/floorPlan.geometry.d.ts +111 -0
- package/dist/types/package/common/floorPlan/floorPlan.graph.d.ts +139 -0
- package/dist/types/package/common/floorPlan/floorPlan.hit.d.ts +26 -0
- package/dist/types/package/common/floorPlan/floorPlan.json.d.ts +23 -0
- package/dist/types/package/common/floorPlan/floorPlan.path.d.ts +59 -0
- package/dist/types/package/common/floorPlan/floorPlan.plan.d.ts +98 -0
- package/dist/types/package/common/floorPlan/floorPlan.rooms.d.ts +47 -0
- package/dist/types/package/common/floorPlan/floorPlan.snap.d.ts +101 -0
- package/dist/types/package/common/floorPlan/floorPlan.types.d.ts +276 -0
- package/dist/types/package/common/floorPlan/index.d.ts +28 -0
- package/dist/types/package/common/floorPlan/legacySvg.d.ts +27 -0
- package/dist/types/package/common/planCanvas/index.d.ts +2 -0
- package/dist/types/package/common/planCanvas/planCanvas.d.ts +7 -0
- package/dist/types/package/common/planCanvas/planCanvas.types.d.ts +62 -0
- package/dist/types/package/common/roomDrawer/roomDrawer.d.ts +3 -3
- package/dist/types/package/common/roomDrawer/roomDrawer.types.d.ts +295 -15
- package/dist/types/package/common/roomDrawer/usePlanHistory.d.ts +24 -0
- package/dist/types/package/common/roomViewer/index.d.ts +1 -0
- package/dist/types/package/common/roomViewer/roomViewer.d.ts +9 -2
- package/dist/types/package/common/roomViewer/roomViewer.types.d.ts +88 -9
- package/dist/types/package/index.d.ts +1 -0
- package/docs/CLAUDE.md +1 -0
- package/docs/ConfigProvider.md +1 -0
- package/docs/FloorPlan.md +214 -0
- package/docs/RoomDrawer.md +330 -30
- package/docs/RoomViewer.md +80 -27
- package/package.json +1 -1
- package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs +0 -6
- package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs.map +0 -1
- package/dist/types/package/common/roomDrawer/roomDrawer.utils.d.ts +0 -32
- package/docs/room-drawing-analysis.md +0 -337
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# FloorPlan
|
|
2
|
+
|
|
3
|
+
**When to use:** The shared data model behind `RoomDrawer` and `RoomViewer`. Reach for it directly when you need to build, inspect, validate or persist a floor plan in code rather than by drawing one — seeding a plan from a database, computing room areas, or generating a layout programmatically.
|
|
4
|
+
|
|
5
|
+
**Keywords:** blueprint, cad, grundriss, apartment, building, storey, partition, area calculation, planar graph, half-edge, extrusion
|
|
6
|
+
|
|
7
|
+
**Import:** `import { emptyFloorPlan, addWall, derivePlan } from '@ahrowe/ui'`
|
|
8
|
+
**Types:** `import type { FloorPlan, PlanFloor, PlanNode, PlanWall, PlanOpening, PlanRoom, DerivedRoom, PlanPoint, PlanView, PlanSelection } from '@ahrowe/ui'`
|
|
9
|
+
|
|
10
|
+
`PlanPoint`, `PlanView` and `PlanSelection` are the ones that also appear in `RoomDrawer` and `RoomViewer` props. Every exported function's own option and result types come with it — `WallAttrs`, `SplitWallResult`, `WallPathResult` and the rest.
|
|
11
|
+
|
|
12
|
+
## The model in one paragraph
|
|
13
|
+
|
|
14
|
+
A plan is a **planar wall graph**. `PlanNode`s are junctions, `PlanWall`s are the edges between them, and a wall is **shared**: where two rooms meet there is one wall, not one per room. Rooms are not stored as shapes at all — they are the enclosed faces the walls happen to form, recomputed by `derivePlan`, with a `PlanRoom` carrying only the name and colour. A `PlanOpening` (door, window, archway, gap) belongs to a **wall**, so a door between two rooms is cut once and both rooms open through it.
|
|
15
|
+
|
|
16
|
+
**Every length is in millimetres.** Coordinates, wall thickness, storey height, opening offsets and widths. There is deliberately no per-plan scale factor: it would have to be threaded through every function, and one missed call site is a silent 1000x error. `unitSystem` affects display formatting only and is never read by geometry.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { emptyFloorPlan, addRect, addOpening, derivePlan, OpeningKind } from '@ahrowe/ui';
|
|
20
|
+
|
|
21
|
+
const plan = emptyFloorPlan({ floorLabel: 'Ground floor' });
|
|
22
|
+
let floor = plan.floors[0];
|
|
23
|
+
|
|
24
|
+
// Two rooms flush against each other. The shared edge becomes ONE wall.
|
|
25
|
+
floor = addRect(floor, { x: 0, y: 0 }, { x: 3000, y: 3000 }).floor;
|
|
26
|
+
floor = addRect(floor, { x: 3000, y: 0 }, { x: 7000, y: 3000 }).floor;
|
|
27
|
+
Object.keys(floor.walls).length; // 7, not 8
|
|
28
|
+
|
|
29
|
+
// Rooms are derived, not drawn.
|
|
30
|
+
const { rooms } = derivePlan(floor);
|
|
31
|
+
rooms.length; // 2
|
|
32
|
+
rooms[0].area; // mm², holes already subtracted
|
|
33
|
+
rooms[0].outer; // the polygon, for rendering
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Enums
|
|
37
|
+
|
|
38
|
+
- `WallType`: `Exterior` | `Interior` | `Partition` | `Virtual` (a room divider with no physical wall: it still closes a face, but extrudes to nothing in 3D)
|
|
39
|
+
- `OpeningKind`: `Door` | `Window` | `Archway` | `Gap`
|
|
40
|
+
- `DoorSwing`: `None` | `StartLeft` | `StartRight` | `EndLeft` | `EndRight` | `Double` | `Sliding`
|
|
41
|
+
- `PlanUnitSystem`: `Metric` | `Imperial` (display only)
|
|
42
|
+
- `PlanIssueKind`: what `validatePlan` reports
|
|
43
|
+
|
|
44
|
+
## Building a plan
|
|
45
|
+
|
|
46
|
+
Every operation is pure and returns `PlanEdit<T>` = `{ floor, result, issues }`, so results drop straight into React state.
|
|
47
|
+
|
|
48
|
+
| Function | Purpose |
|
|
49
|
+
|----------|---------|
|
|
50
|
+
| `addWall(floor, a, b, attrs?, tol?)` | Adds a wall, **splitting anything it crosses** so the graph stays planar |
|
|
51
|
+
| `addRect(floor, corner, opposite, attrs?, tol?)` | Four walls at once, welding onto anything already there |
|
|
52
|
+
| `splitWall(floor, wallId, at)` | Cuts a wall at a point or 0..1 parameter; redistributes its openings |
|
|
53
|
+
| `mergeNodes(floor, keepId, removeId)` | Welds two nodes, deduplicating the walls that collapse together |
|
|
54
|
+
| `dissolveNode(floor, nodeId)` | Rejoins two collinear same-construction walls; the inverse of `splitWall` |
|
|
55
|
+
| `removeWall(floor, wallId, opts?)` | Removes a wall, its openings, and any node left orphaned |
|
|
56
|
+
| `moveNode` / `transformPlan` / `scalePlan` | Move one node, translate everything, or rescale everything |
|
|
57
|
+
| `addOpening` / `updateOpening` / `removeOpening` | Openings, clamped to fit their wall |
|
|
58
|
+
| `validatePlan(floor)` | Every way the graph currently breaks the planar invariant |
|
|
59
|
+
| `cleanupPlan(floor)` | Removes what can safely be removed. Idempotent |
|
|
60
|
+
|
|
61
|
+
**`addWall` is the one that matters.** It resolves each endpoint (reusing a nearby node, splitting a wall it lands on, or creating one), then splits every wall it crosses and cuts itself at every node it passes through. That is what makes a second rectangle drawn flush against the first share the existing edge instead of laying a duplicate on top of it.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// Drawing across an existing wall splits both, leaving four walls and a new junction.
|
|
65
|
+
floor = addWall(floor, { x: 0, y: 0 }, { x: 4000, y: 0 }).floor;
|
|
66
|
+
floor = addWall(floor, { x: 2000, y: -2000 }, { x: 2000, y: 2000 }).floor;
|
|
67
|
+
Object.keys(floor.walls).length; // 4
|
|
68
|
+
validatePlan(floor); // []
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Tolerances.** `addWall`'s `tol` is the welding distance in millimetres. Pass the value a user's pointer tolerance works out to in world units (see `resolveSnap` below); the default is exact-match only.
|
|
72
|
+
|
|
73
|
+
## Deriving rooms
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
const { floor: refreshed, rooms, issues } = derivePlan(floor);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`derivePlan` extracts the faces and attaches stored metadata. It is pure and **never creates or deletes a `PlanRoom`**, so it is safe to call on every render. Memoise it on `plan.revision`, which every mutation bumps.
|
|
80
|
+
|
|
81
|
+
`commitDerivation(floor)` mints a `PlanRoom` for each face that matched nothing — call it once per committed edit, not per render.
|
|
82
|
+
|
|
83
|
+
Each `DerivedRoom` carries the face (`outer`, `holes`, `area`, `perimeter`, `wallIds`), the matched `meta`, and an `anchor` guaranteed to be **inside** the polygon (an area centroid can fall outside a concave room, so it is computed, not averaged).
|
|
84
|
+
|
|
85
|
+
### How a room keeps its name
|
|
86
|
+
|
|
87
|
+
Identity is primarily the **set of boundary wall ids**, with the anchor point as a secondary signal and area as a tie-break. That ordering is what makes it robust:
|
|
88
|
+
|
|
89
|
+
- **Reshape or drag the whole plan** — the wall set is unchanged, so it matches regardless of where the anchors ended up.
|
|
90
|
+
- **Split a room** — both children share half the old boundary, so the **anchor decides**: the child containing it keeps the name, the other becomes a new room. Predictable beats plausible.
|
|
91
|
+
- **Merge two rooms** — one wins on area (the larger), and **the loser is kept, not deleted**. It is reported as `PlanIssueKind.UnmatchedRoom`, and re-adding the wall restores it to the right child by itself. Call `pruneUnmatchedRooms` only when you actually want them gone.
|
|
92
|
+
|
|
93
|
+
Set `anchorPinned` on a `PlanRoom` to stop `derivePlan` moving its label.
|
|
94
|
+
|
|
95
|
+
### Free-standing walls
|
|
96
|
+
|
|
97
|
+
A wall with a loose end does not create a room and does not break the one it sits in. The face walk goes out along it and straight back, so the room's fill, area and hit-testing are unaffected. Two consequences: the face polygon contains a zero-width slit (so it is not a *simple* polygon — run `stripSpurs` before triangulating a floor slab), and that wall's id appears **twice** in `wallIds`.
|
|
98
|
+
|
|
99
|
+
## Rendering walls
|
|
100
|
+
|
|
101
|
+
`buildWallPaths(floor)` returns `{ groups, caps }` for a two-pass stroke. Both passes stroke the **same** path data: the outline pass at `thickness`, the fill pass at `thickness - 2 * outlineWidth`. The visible wall outline is not drawn at all, it is what is left of the first pass after the second covers its middle.
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
const { groups, caps } = buildWallPaths(floor);
|
|
105
|
+
const ow = 1 * pxToWorld; // outline width, screen-constant
|
|
106
|
+
|
|
107
|
+
<g fill="none" strokeLinecap="butt" strokeLinejoin="miter" strokeMiterlimit={4}>
|
|
108
|
+
{groups.map((g) => <path key={g.thickness} d={g.d} strokeWidth={g.thickness} stroke="var(--border-color)" />)}
|
|
109
|
+
</g>
|
|
110
|
+
<g fill="none" strokeLinecap="butt" strokeLinejoin="miter" strokeMiterlimit={4}>
|
|
111
|
+
{groups.map((g) => g.thickness - 2 * ow > 0 && (
|
|
112
|
+
<path key={g.thickness} d={g.d} strokeWidth={g.thickness - 2 * ow} stroke="var(--background-accent)" />
|
|
113
|
+
))}
|
|
114
|
+
</g>
|
|
115
|
+
<g stroke="var(--border-color)" strokeWidth={ow}>
|
|
116
|
+
{caps.map((c, i) => <line key={i} x1={c.a.x} y1={c.a.y} x2={c.b.x} y2={c.b.y} />)}
|
|
117
|
+
</g>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Rules that make this work:
|
|
121
|
+
|
|
122
|
+
- **Every outline path before every fill path.** One `<g>` each, never interleaved pairs.
|
|
123
|
+
- **`caps` must be drawn.** Nothing is ever drawn across a butt end, because both passes stop at the same coordinate, so opening jambs, free wall ends and thickness changes need explicit lines. Each cap says which (`reason: 'jamb' | 'freeEnd' | 'thicknessStep'`).
|
|
124
|
+
- **`outlineWidth` is screen-constant**, so recompute `thickness - 2 * ow` on zoom. The path data itself never changes. Skip the fill pass entirely when it would go non-positive (zoomed far out, walls become solid lines, which is correct at that scale).
|
|
125
|
+
|
|
126
|
+
- **`strokeLinejoin` and `strokeMiterlimit` must be set**, and set the same on both passes. Corners are filled by the join, not by extra geometry.
|
|
127
|
+
|
|
128
|
+
Walls are emitted as chains through corners so `stroke-linejoin: miter` fills them. Where a chain has to stop, at a junction or a thickness change, most corners still need nothing: both walls' bands start at the node, so across a gap of up to 180 degrees they already reach each other. The exception is a gap **wider** than 180 degrees, which happens when every wall at a node points into the same half-plane; there the two faces diverge and leave a wedge bitten out of the node, and a small hinged subpath is emitted to mitre it. At most one gap at a node can be reflex, so there is never more than one. Different thicknesses become separate groups, since one path can carry only one stroke width.
|
|
129
|
+
|
|
130
|
+
`buildWallPaths(floor, { wallIds })` builds the same paths for a subset of walls, taking node degrees from the whole floor so chains break and corners fill exactly where they do in the full drawing. That is what lets a room's boundary be highlighted by painting over the walls it is bounded by, openings and all.
|
|
131
|
+
|
|
132
|
+
`wallCentrelineSpans(floor, wall)` gives the stretches that are actually wall, in millimetres from node `a`, with openings removed and overlaps merged. `openingGeometry(floor, opening)` gives the centre, direction, normal and endpoints for drawing a door leaf, swing arc or window symbol.
|
|
133
|
+
|
|
134
|
+
## Addressing a plan
|
|
135
|
+
|
|
136
|
+
`getFloor(plan, floorId?)` reads one floor, defaulting to the lowest. `updateFloor(plan, floorId, edit)` applies a floor-level edit and bumps `revision` for you; `setFloor` replaces one outright. `floorBounds(floor)` gives the bounding box, padded by half the thickest wall so the outer face of every wall is inside it, which is what a fit-to-view needs. `renameFloor`, `wallLength`, `floorIndex` and `DEFAULT_WALL_ATTRS` round out the small helpers.
|
|
137
|
+
|
|
138
|
+
## Floors
|
|
139
|
+
|
|
140
|
+
`FloorPlan.floors` is a stack of storeys, **bottom first**: index 0 is the lowest, and `elevation` rises with the index.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { addFloor, duplicateFloor, removeFloor, moveFloor, setFloorHeight, floorBelow } from '@ahrowe/ui';
|
|
144
|
+
|
|
145
|
+
const { plan: two, floor } = addFloor(plan); // empty storey on top
|
|
146
|
+
const { plan: three } = duplicateFloor(two, floor.id); // copy of it, above it
|
|
147
|
+
const below = floorBelow(three, floor.id); // what an underlay traces
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Every one of these re-stacks the elevations behind you, so each storey sits on the one below: insert a floor and the ones above it rise, remove one and they drop, raise a storey's height with `setFloorHeight` and everything over it lifts, and the building's own ground level stays where it was. This is also why a storey's height is not set with a plain `setFloor`, which would leave the floors above buried in it or floating over it — neither of which a 2D plan would show. Without that, a gap or an overlap only turns up in 3D, long after the edit that caused it.
|
|
151
|
+
|
|
152
|
+
`duplicateFloor` regenerates **every** id and rewires every reference — walls to the copy's nodes, openings to its walls, rooms to its wall ids — so the two storeys share nothing and an edit to one cannot reach into the other. A room's `wallIds` in particular must point at the copy's own walls, or the first `derivePlan` would strand every room in it.
|
|
153
|
+
|
|
154
|
+
`setFloorHeight` clamps to `MIN_FLOOR_HEIGHT` (500mm), low enough for a crawl space and high enough to stop a zero-height storey. `removeFloor` refuses to remove the last storey: a plan with nowhere to draw would need a special case in every consumer.
|
|
155
|
+
|
|
156
|
+
## Saving and loading
|
|
157
|
+
|
|
158
|
+
JSON is the interchange format. `planToJson` writes it; `planFromJson` reads it back, and does so defensively, because a file off a disk is untrusted and whatever comes out is rendered immediately.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { planToJson, planFromJson } from '@ahrowe/ui';
|
|
162
|
+
|
|
163
|
+
const text = planToJson(plan); // indented; pass { pretty: false } for one line
|
|
164
|
+
const { plan: loaded, issues } = planFromJson(text);
|
|
165
|
+
if (!loaded) console.error(issues); // unreadable
|
|
166
|
+
else if (issues.length) console.warn(issues); // loaded, but repaired
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`planFromJson` rebuilds the plan field by field rather than casting the parsed object, so no unchecked field reaches a renderer. It repairs wherever the repair is unambiguous — a numeric string becomes a number, an unknown enum value falls back, a wall with missing corners or an opening with no wall is dropped — and returns a `null` plan only when the text is not JSON, is not an object, has no `floors`, or carries a `version` other than `PLAN_FORMAT_VERSION`. A room whose walls have gone is kept rather than dropped: re-adding the wall re-matches it by name.
|
|
170
|
+
|
|
171
|
+
`revision` is bumped on load, since `derivePlan` memoises on it and the repaired plan is not the one the file described.
|
|
172
|
+
|
|
173
|
+
## Snapping and pointer input
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
const world = clientToWorld(e.clientX, e.clientY, svg.getBoundingClientRect(), view);
|
|
177
|
+
const snap = resolveSnap(world, { floor, pxToWorld: 1 / view.zoom, origin, options });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`clientToWorld` **only converts** — snapping is `resolveSnap`'s job alone, which is what makes "hold Alt to disable snapping" possible at all.
|
|
181
|
+
|
|
182
|
+
Every tolerance in `SnapOptions` is in **screen pixels**, converted through `pxToWorld` at the point of use. A fixed world-unit tolerance covers less and less of the screen as you zoom in until it stops working.
|
|
183
|
+
|
|
184
|
+
`resolveSnap` runs in two stages, because half of these are constraints rather than points. A hard snap (`Node`, `Intersection`, `Wall`) fixes both degrees of freedom, so the first match wins. A constraint (`Angle`, `Extension`, `Alignment`) removes only one: two of them intersect, one is projected onto and then slid **along** to the nearest grid position. That last detail is what lets angle lock and grid snap coexist — you get exactly 45° at a round length, rather than a point that is neither.
|
|
185
|
+
|
|
186
|
+
Pass `options.enabled: []` to disable snapping entirely.
|
|
187
|
+
|
|
188
|
+
## Validation
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
const issues = validatePlan(floor); // O(walls²) — development and tests, not per keystroke
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The invariant that matters: **no two walls may cross without a node at the intersection, and no node may sit on a wall's interior without that wall being split there.** Break either and the face walk silently merges two rooms into one figure-eight face with no error anywhere, which is why `UnsplitCrossing` and `UnsplitNodeOnWall` exist as explicit issue kinds. Everything built through `addWall` maintains this automatically.
|
|
195
|
+
|
|
196
|
+
## Importing older plans
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
import { planFromLegacySvg } from '@ahrowe/ui';
|
|
200
|
+
|
|
201
|
+
// The old canvas was unitless; pick the factor that makes it millimetres.
|
|
202
|
+
// A 400-unit-wide export meant to be a 12m house wants scale: 30.
|
|
203
|
+
const plan = planFromLegacySvg(svgText, { scale: 30 });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Feeding every legacy polygon edge through `addWall` is what collapses the old duplicated walls: the second room's copy of a shared edge resolves to the same two nodes and is recognised as already walled. Room names carry across, and doors are recovered where the old swing-arc path is recognisable; anything that is not is skipped rather than guessed at.
|
|
207
|
+
|
|
208
|
+
## Ready for 3D
|
|
209
|
+
|
|
210
|
+
The model carries everything an extrusion needs and nothing it would have to invent: `PlanWall.thickness` / `height` / `baseHeight`, `PlanOpening.sill` / `head`, `PlanFloor.elevation` / `height`, and derived floor polygons. No 3D renderer ships today.
|
|
211
|
+
|
|
212
|
+
These are the fields that do nothing in 2D, and that is expected rather than a gap: a plan is a horizontal cut, so a window 900mm up the wall and a door at floor level are the same hole seen from above. `sill` is the height of an opening's bottom above the floor and `head` the height of its top; the drawer edits `sill` in its selection bar so the value is captured while the plan is being drawn, rather than having to be filled in for every opening afterwards.
|
|
213
|
+
|
|
214
|
+
One thing to know when you build one: `PlanFace.outer` is positively wound under the shoelace formula, which in SVG's y-down space reads as **visually clockwise**. Anything assuming counter-clockwise (slab normals, most triangulators) must flip it, and should run `stripSpurs` first so a free-standing wall's zero-width slit does not produce degenerate triangles.
|
package/docs/RoomDrawer.md
CHANGED
|
@@ -1,53 +1,353 @@
|
|
|
1
1
|
# RoomDrawer
|
|
2
2
|
|
|
3
|
-
**When to use:**
|
|
3
|
+
**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.
|
|
4
4
|
|
|
5
|
-
**Keywords:**
|
|
5
|
+
**Keywords:** blueprint, cad, sketch, grundriss, apartment, house, layout designer, interior, space planner, architect
|
|
6
6
|
|
|
7
7
|
**Import:** `import { RoomDrawer } from '@ahrowe/ui'`
|
|
8
|
-
**Types:** `import type { RoomDrawerProps,
|
|
8
|
+
**Types:** `import type { RoomDrawerProps, RoomDrawerHandle, RoomDrawerTool } from '@ahrowe/ui'`
|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { useState } from 'react';
|
|
14
|
+
import { RoomDrawer, emptyFloorPlan } from '@ahrowe/ui';
|
|
15
|
+
import type { FloorPlan } from '@ahrowe/ui';
|
|
16
|
+
|
|
17
|
+
// Controlled
|
|
18
|
+
const [plan, setPlan] = useState<FloorPlan>(() => emptyFloorPlan());
|
|
19
|
+
<RoomDrawer value={plan} onChange={setPlan} />
|
|
20
|
+
|
|
21
|
+
// Uncontrolled, reacting only to committed edits
|
|
22
|
+
<RoomDrawer defaultValue={emptyFloorPlan()} onChangeEnd={(plan) => save(plan)} />
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Tools
|
|
26
|
+
|
|
27
|
+
| Tool | Key | What it does |
|
|
28
|
+
|------|-----|--------------|
|
|
29
|
+
| `select` | `V` | Click to select, drag to edit |
|
|
30
|
+
| `wall` | `W` | Click to place corners, building a chain of single walls |
|
|
31
|
+
| `rect` | `R` | Drag out a rectangular room in one gesture |
|
|
32
|
+
| `door` | `D` | Click a wall to cut a door into it |
|
|
33
|
+
| `window` | `N` | Click a wall to cut a window |
|
|
34
|
+
| `archway` | `A` | Click a wall to cut an opening with no leaf |
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
// Restrict the toolbar, e.g. a kiosk that only places doors on an existing plan
|
|
38
|
+
<RoomDrawer value={plan} onChange={setPlan} tools={['select', 'door']} />
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**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.
|
|
42
|
+
|
|
43
|
+
**Dragging with `select`:**
|
|
44
|
+
|
|
45
|
+
- a corner reshapes the rooms that meet there, and dropping it onto another corner welds them
|
|
46
|
+
- 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)
|
|
47
|
+
- an opening slides along its wall
|
|
48
|
+
- a room moves every corner it owns
|
|
49
|
+
|
|
50
|
+
## Measuring without drawing
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
**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.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
## Selecting several things
|
|
61
|
+
|
|
62
|
+
Two gestures, because they suit different jobs:
|
|
63
|
+
|
|
64
|
+
- **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.
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
67
|
+
`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.
|
|
68
|
+
|
|
69
|
+
**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.
|
|
70
|
+
|
|
71
|
+
**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.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
Set `multiSelect={false}` to keep the drawer single-select: Ctrl-click then just selects, the marquee is disabled, and `Ctrl+A` does nothing.
|
|
76
|
+
|
|
77
|
+
**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.
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
## Snapping and angles
|
|
82
|
+
|
|
83
|
+
Three latching toggles sit in the toolbar, each with a hotkey:
|
|
84
|
+
|
|
85
|
+
| Toggle | Key | What it does |
|
|
86
|
+
|--------|-----|--------------|
|
|
87
|
+
| Snap | `S` | Snap to corners, walls, wall crossings, wall extensions, alignments with existing corners, and the grid |
|
|
88
|
+
| Angle lock | `L` | Constrain every new or dragged point to a multiple of `constrainAngle` (45° by default) |
|
|
89
|
+
| Grid | `G` | Show the grid |
|
|
90
|
+
|
|
91
|
+
`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.
|
|
92
|
+
|
|
93
|
+
**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.
|
|
94
|
+
|
|
95
|
+
**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.
|
|
96
|
+
|
|
97
|
+
**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.
|
|
98
|
+
|
|
99
|
+
**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.
|
|
100
|
+
|
|
101
|
+
**What gets a measurement depends on what you selected**, because in each case a different set of walls is the one actually changing:
|
|
102
|
+
|
|
103
|
+
- a **corner**: every wall meeting it, since reshaping the corner changes all of them and one figure would be ambiguous about which
|
|
104
|
+
- 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
|
|
105
|
+
- 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
|
|
106
|
+
- 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
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
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.
|
|
9
111
|
|
|
10
112
|
```tsx
|
|
11
|
-
|
|
113
|
+
// Start latched to 30° steps, for a non-rectangular building
|
|
114
|
+
<RoomDrawer value={plan} onChange={setPlan} angleLock constrainAngle={30} />
|
|
12
115
|
|
|
13
|
-
|
|
116
|
+
// No snapping at all, and no guides
|
|
117
|
+
<RoomDrawer value={plan} onChange={setPlan} snap={false} showSnapGuides={false} />
|
|
14
118
|
```
|
|
15
119
|
|
|
16
|
-
|
|
120
|
+
The `snap`, `angleLock` and `showGrid` props seed the toggles; changing one resets that latch.
|
|
121
|
+
|
|
122
|
+
## Keyboard
|
|
123
|
+
|
|
124
|
+
| Key | Action |
|
|
125
|
+
|-----|--------|
|
|
126
|
+
| `V` `W` `R` `D` `N` `A` | Switch tool |
|
|
127
|
+
| `S` / `L` / `G` | Toggle snapping / angle lock / grid |
|
|
128
|
+
| `Ctrl/Cmd+A` | Select everything |
|
|
129
|
+
| `Ctrl/Cmd`-click | Add or remove one thing from the selection |
|
|
130
|
+
| `Shift` (hold) | Momentarily invert angle lock |
|
|
131
|
+
| `Alt` (hold) | Momentarily invert snapping |
|
|
132
|
+
| `Esc` | Cancel the wall chain, or clear the selection |
|
|
133
|
+
| `Enter` | Finish an open wall chain |
|
|
134
|
+
| `Delete` / `Backspace` | Delete the selection |
|
|
135
|
+
| `Ctrl/Cmd+Z` | Undo |
|
|
136
|
+
| `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` | Redo |
|
|
137
|
+
| `+` / `-` | Zoom |
|
|
138
|
+
| `0` | Fit the plan to the viewport |
|
|
139
|
+
| Arrow keys | Nudge the selection by one grid step (`Shift` for ten) |
|
|
17
140
|
|
|
18
|
-
|
|
19
|
-
- `rect` — drag to draw a rectangular room
|
|
20
|
-
- `polygon` — click to place corners, click first point or press Enter to close, Esc to cancel
|
|
21
|
-
- `door` — click on a wall segment to place a door
|
|
141
|
+
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.
|
|
22
142
|
|
|
23
|
-
**
|
|
143
|
+
**What Delete removes**, which is the one genuinely ambiguous case:
|
|
24
144
|
|
|
25
|
-
**
|
|
145
|
+
- 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.
|
|
146
|
+
- 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
|
|
147
|
+
- a wall: the wall and its openings
|
|
148
|
+
- an opening: just the opening, and the wall heals
|
|
149
|
+
- a room: its name, and **only the walls no other room is using**. A wall the room next door still needs stays.
|
|
26
150
|
|
|
27
|
-
|
|
28
|
-
type Tool = 'select' | 'rect' | 'polygon' | 'door';
|
|
151
|
+
## Pointer and touch
|
|
29
152
|
|
|
30
|
-
|
|
153
|
+
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.
|
|
31
154
|
|
|
32
|
-
|
|
33
|
-
id: string;
|
|
34
|
-
name: string;
|
|
35
|
-
points: Point[];
|
|
36
|
-
}
|
|
155
|
+
## Controlled, uncontrolled, and the two change callbacks
|
|
37
156
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
157
|
+
`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`.
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
<RoomDrawer
|
|
161
|
+
value={plan}
|
|
162
|
+
onChange={setPlan} // every frame of a drag
|
|
163
|
+
onChangeEnd={(plan) => savePlan(plan)} // once, when the drag lands
|
|
164
|
+
/>
|
|
45
165
|
```
|
|
46
166
|
|
|
47
|
-
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
## Undo and redo
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
<RoomDrawer value={plan} onChange={setPlan} historyMode="none" /> // you own history
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
## Imperative handle
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
const ref = useRef<RoomDrawerHandle>(null);
|
|
183
|
+
<RoomDrawer ref={ref} value={plan} onChange={setPlan} />
|
|
184
|
+
|
|
185
|
+
ref.current?.fitToContent();
|
|
186
|
+
ref.current?.undo();
|
|
187
|
+
ref.current?.select({ type: 'room', id: roomId });
|
|
188
|
+
ref.current?.deleteSelection();
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Method | Description |
|
|
192
|
+
|--------|-------------|
|
|
193
|
+
| `getPlan()` / `setPlan(plan, options?)` | Read or replace the plan. `{ resetHistory: true }` drops the undo stack |
|
|
194
|
+
| `getFloorId()` / `setFloorId(id)` | The storey being edited |
|
|
195
|
+
| `addFloor(options?)` / `duplicateFloor()` | Add an empty storey above, or copy this one. Both switch to the new floor and return its id |
|
|
196
|
+
| `removeFloor(id?)` / `renameFloor(id, label)` | Remove (never the last one) or rename a storey |
|
|
197
|
+
| `moveFloor(id, toIndex)` / `setFloorHeight(id, mm)` | Reorder a storey, or set its height. Both re-stack the elevations |
|
|
198
|
+
| `clear()` | Empty the plan, as one history entry |
|
|
199
|
+
| `clearMeasurements()` | Remove every ruler measurement. View state, so it never reaches `onChange` |
|
|
200
|
+
| `download(fileName?)` | Write the plan to a JSON file, as the toolbar button does |
|
|
201
|
+
| `loadJson(text)` | Load JSON text as a plan, as one undoable entry. Returns a `PlanParseResult` |
|
|
202
|
+
| `undo()` / `redo()` / `canUndo()` / `canRedo()` / `clearHistory()` | History |
|
|
203
|
+
| `getSelection()` / `select(selection)` | Read or replace the selection. `select` also accepts a single entry or `null` |
|
|
204
|
+
| `addToSelection(entries)` / `toggleSelection(entry)` / `selectAll()` | Adjust the selection without replacing it |
|
|
205
|
+
| `deleteSelection()` | Delete everything selected |
|
|
206
|
+
| `getView()` / `setView(view)` / `zoomBy(factor)` / `fitToContent(options?)` | Viewport |
|
|
207
|
+
| `toWorld(clientX, clientY)` / `toScreen(point)` | Coordinate conversion |
|
|
208
|
+
| `getSvgElement()` | The live `<svg>`, for measuring or screenshotting |
|
|
209
|
+
|
|
210
|
+
## Key props
|
|
48
211
|
|
|
49
212
|
| Prop | Type | Description |
|
|
50
213
|
|------|------|-------------|
|
|
51
|
-
| `
|
|
214
|
+
| `value` / `defaultValue` | `FloorPlan` | Controlled / initial plan |
|
|
215
|
+
| `onChange` | `(plan, meta) => void` | Every mutation, including mid-drag |
|
|
216
|
+
| `onChangeEnd` | `(plan, meta) => void` | Once per committed edit |
|
|
217
|
+
| `floorId` | `string` | Which floor to edit (default: the first) |
|
|
218
|
+
| `tool` / `defaultTool` / `onToolChange` | `RoomDrawerTool` | Active tool |
|
|
219
|
+
| `tools` | `RoomDrawerTool[]` | Restricts and orders the toolbar |
|
|
220
|
+
| `selection` / `defaultSelection` / `onSelectionChange` | `PlanSelection[]` | What is selected. An array: a single selection is a one-element array |
|
|
221
|
+
| `multiSelect` | `boolean` | Allow selecting several things at once (default `true`) |
|
|
222
|
+
| `onHoverChange` | `(target) => void` | Fires as the pointer moves over entities |
|
|
223
|
+
| `view` / `defaultView` / `onViewChange` | `PlanView` | Viewport (`x`, `y`, `zoom` in screen px per mm) |
|
|
224
|
+
| `minZoom` / `maxZoom` | `number` | Zoom bounds (default `0.005` / `2`) |
|
|
225
|
+
| `fitOnMount` | `boolean` | Fit the plan into view once there is something to fit (default `true`) |
|
|
226
|
+
| `gridSize` | `number` | Grid spacing in millimetres (default `100`) |
|
|
227
|
+
| `showGrid` | `boolean` | Default `true` |
|
|
228
|
+
| `snap` | `boolean` | Seeds the snap toggle (default `true`); `Alt` inverts it while held |
|
|
229
|
+
| `angleLock` | `boolean` | Seeds the angle lock toggle (default `false`); `Shift` inverts it while held |
|
|
230
|
+
| `constrainAngle` | `number` | Angle step in degrees that the lock snaps to (default `45`) |
|
|
231
|
+
| `showSnapGuides` | `boolean` | Draw the construction lines explaining each snap (default `true`) |
|
|
232
|
+
| `wallThickness` / `wallHeight` | `number` | Millimetres, applied to newly drawn walls |
|
|
233
|
+
| `openingDefaults` | `Partial<Record<OpeningKind, { width?, sill?, head? }>>` | Size of newly placed openings |
|
|
234
|
+
| `showMeasurements` | `boolean` | Live length, angle and area readouts while drawing (default `true`) |
|
|
235
|
+
| `formatLength` / `formatArea` | `(mm) => string` | Override the readouts |
|
|
236
|
+
| `showToolbar` / `showInspector` / `showHint` / `showHistoryControls` / `showFileControls` / `showFloorBar` | `boolean` | Hide parts of the chrome |
|
|
237
|
+
| `floorId` / `defaultFloorId` / `onFloorChange` | `string` / `string` / `(id) => void` | Which storey is being edited |
|
|
238
|
+
| `showUnderlay` | `boolean` | Trace the storey below behind this one (default `true`) |
|
|
239
|
+
| `fileName` | `string` | Name for the downloaded file; `.json` is appended if missing. Defaults to the plan's `name`, else `floor-plan.json` |
|
|
240
|
+
| `onImport` | `(result: PlanParseResult) => void` | Fires after every file opened, whether it worked or not |
|
|
241
|
+
| `historyMode` | `'internal' \| 'none'` | Default `'internal'` |
|
|
242
|
+
| `historyLimit` | `number` | Maximum undo depth (default `50`) |
|
|
243
|
+
| `readOnly` | `boolean` | View and select, but no edits |
|
|
244
|
+
| `disabled` | `boolean` | No interaction at all |
|
|
245
|
+
| `canvasHeight` | `string` | Any CSS length (default `'420px'`) |
|
|
246
|
+
| `getRoomColor` / `renderRoomLabel` | | Per-room fill colour and label content |
|
|
247
|
+
| `labels` / `icons` | `RoomDrawerLabels` / `RoomDrawerIcons` | Localise or re-icon the chrome |
|
|
248
|
+
|
|
249
|
+
`RoomDrawerProps` extends the full native `div` attribute set plus `data-*`, so `id`, `title`, `aria-*` and the rest pass through to the root.
|
|
250
|
+
|
|
251
|
+
## Floors
|
|
252
|
+
|
|
253
|
+
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.
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
<RoomDrawer defaultFloorId={groundId} onFloorChange={setFloorId} /> {/* uncontrolled */}
|
|
257
|
+
<RoomDrawer floorId={floorId} onFloorChange={setFloorId} /> {/* controlled */}
|
|
258
|
+
<RoomDrawer showFloorBar={false} /> {/* drive it yourself */}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
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.
|
|
262
|
+
|
|
263
|
+
**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.
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
<RoomDrawer showUnderlay={false} />
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**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.
|
|
270
|
+
|
|
271
|
+
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.
|
|
272
|
+
|
|
273
|
+
Every floor edit — add, copy, delete, rename, reorder, height — goes through history, so `Ctrl+Z` takes it back like any other edit.
|
|
274
|
+
|
|
275
|
+
Floors are part of the plan document, so they survive a download and reopen without anything extra.
|
|
276
|
+
|
|
277
|
+
## The selection bar
|
|
278
|
+
|
|
279
|
+
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.
|
|
280
|
+
|
|
281
|
+
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`, `selectedOpening`, `selectedCount`, `selectedNothing`), and hide the bar with `showInspector={false}`.
|
|
282
|
+
|
|
283
|
+
**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`.
|
|
284
|
+
|
|
285
|
+
## Saving and opening files
|
|
286
|
+
|
|
287
|
+
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.
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
<RoomDrawer showFileControls={false} /> {/* build your own UI instead */}
|
|
291
|
+
<RoomDrawer fileName="flat-3.json" /> {/* default: the plan's own name */}
|
|
292
|
+
<RoomDrawer onImport={(r) => console.log(r.issues)} />
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**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:
|
|
296
|
+
|
|
297
|
+
- 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
|
|
298
|
+
- 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
|
|
299
|
+
- a thickness of `"250"` is read as `250`, and an unrecognised wall type or opening kind falls back to a known one
|
|
300
|
+
- 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
|
|
301
|
+
|
|
302
|
+
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.
|
|
303
|
+
|
|
304
|
+
To accept a file from your own UI, a drop target or a server, hand the text to `loadJson`:
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
ref.current?.loadJson(await file.text());
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Theming
|
|
311
|
+
|
|
312
|
+
All colours come from theme variables. These control the plan itself, and each falls back to a general theme colour when unset:
|
|
313
|
+
|
|
314
|
+
| Variable | Falls back to |
|
|
315
|
+
|----------|---------------|
|
|
316
|
+
| `--plan-canvas-background` | `var(--background)` |
|
|
317
|
+
| `--plan-grid-color` | `var(--text-color)` at low opacity |
|
|
318
|
+
| `--plan-room-fill` | `var(--background-accent)` |
|
|
319
|
+
| `--plan-room-fill-hover` | `var(--background-accent-light)` |
|
|
320
|
+
| `--plan-room-edge-selected` / `--plan-room-edge-selected-fill` | `var(--primary-color)` / `var(--primary-lighter)` |
|
|
321
|
+
| `--plan-wall-fill` | `var(--text-dark)` |
|
|
322
|
+
| `--plan-wall-outline` | `var(--text-color)` |
|
|
323
|
+
| `--plan-wall-miter-limit` | `4` (see below) |
|
|
324
|
+
| `--plan-door-color` | `var(--text-dark)` |
|
|
325
|
+
| `--plan-window-color` | `var(--info-color)` |
|
|
326
|
+
|
|
327
|
+
**`--plan-wall-miter-limit`** 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.
|
|
328
|
+
|
|
329
|
+
**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.
|
|
330
|
+
|
|
331
|
+
The selection highlight is built from **the same geometry as the walls themselves** and drawn in the same two passes, so it inherits their mitred 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.
|
|
332
|
+
|
|
333
|
+
## Accessibility
|
|
334
|
+
|
|
335
|
+
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).
|
|
336
|
+
|
|
337
|
+
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.
|
|
338
|
+
|
|
339
|
+
## Global defaults
|
|
340
|
+
|
|
341
|
+
Adopts `ConfigProvider`, e.g. `defaultProps={{ RoomDrawer: { gridSize: 250, showGrid: false } }}`. See [ConfigProvider.md](ConfigProvider.md).
|
|
342
|
+
|
|
343
|
+
## Migration from the previous version
|
|
344
|
+
|
|
345
|
+
The previous `RoomDrawer` had no data contract at all: `RoomDrawerProps` was `{ className }`, and the only way data left the component was a browser file download. Everything below is new rather than changed, so the old usage still renders, it just starts empty.
|
|
346
|
+
|
|
347
|
+
- `Tool`, `RoomData`, `DoorData`, `Point` are gone. Use `RoomDrawerTool` and the `FloorPlan` model.
|
|
348
|
+
- `selection` is an array rather than a single entry, since the drawer supports multi-select. A single selection is `[entry]`, and nothing selected is `[]` rather than `null`.
|
|
349
|
+
- The SVG download and upload buttons are gone. The plan JSON is the interchange format now. To read an SVG exported by the old version, use `planFromLegacySvg` (see [FloorPlan.md](FloorPlan.md)), which also collapses each duplicated room boundary into the single shared wall it should have been.
|
|
350
|
+
- The `polygon` tool is now `wall`, which draws open chains as well as closed ones.
|
|
351
|
+
- Removing a corner was a double-click, which was undiscoverable and impossible on touch. Select it and press `Delete` instead.
|
|
52
352
|
|
|
53
|
-
**
|
|
353
|
+
**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`
|