@ahrowe/ui 0.26.0 → 0.27.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.
@@ -94,6 +94,51 @@ export interface DissolveNodeOptions {
94
94
  export declare function dissolveNode(floor: PlanFloor, nodeId: string, options?: DissolveNodeOptions): PlanEdit<{
95
95
  wallId: string;
96
96
  } | null>;
97
+ export interface DetachWallResult {
98
+ /** The wall's end nodes after the detach, in `[a, b]` order. */
99
+ nodeIds: [string, string];
100
+ /** Connecting walls minted where an end was shared with a neighbour. */
101
+ connectorIds: string[];
102
+ /** Ends that became a straight seam once the wall left, and were dissolved away. */
103
+ dissolvedNodeIds: string[];
104
+ }
105
+ /**
106
+ * Pulls a wall off the nodes it shares with its neighbours and moves it by `delta`,
107
+ * leaving a connecting wall behind at each end that was shared.
108
+ *
109
+ * This is the extrude half of dragging a wall. A plain drag moves the shared end nodes,
110
+ * so everything meeting the wall follows it; here the neighbours keep their ends and
111
+ * only the new connectors grow, which is what you want when a partition joins the wall
112
+ * halfway along and should stay put.
113
+ *
114
+ * A free end is only moved: there is nothing there to detach from, and a connector would
115
+ * be zero length. For the same reason `delta` must be at least `MIN_WALL_LENGTH` long —
116
+ * below that the caller should `moveNode` both ends instead.
117
+ *
118
+ * The connector continues whatever was already at the corner (its thickness, type and
119
+ * heights) when exactly one wall was, so pulling a wall off a plain corner redraws
120
+ * exactly as a plain drag would: the seam that leaves behind is straight and is
121
+ * dissolved before returning. At a junction there is no single wall to continue, so the
122
+ * connector takes the dragged wall's own construction.
123
+ */
124
+ export declare function detachWall(floor: PlanFloor, wallId: string, delta: PlanPoint): PlanEdit<DetachWallResult | null>;
125
+ /**
126
+ * Collapses every wall shorter than `minLength` by merging its two ends together.
127
+ *
128
+ * Squashing a wall to nothing is the one thing a drag can do that the model forbids, and
129
+ * it is easy to do by accident: pushing a wall back onto the line it was detached from,
130
+ * or dragging a corner past the one next to it. Deleting the short wall is the wrong
131
+ * repair — it leaves two nodes sitting on the same point with the plan split between
132
+ * them, which reads as two corners stacked on top of each other and derives as two
133
+ * rooms. Merging is lossless, since the two ends are within `minLength` of each other by
134
+ * definition.
135
+ *
136
+ * The end with more walls on it survives, so the corner that was already there keeps its
137
+ * position and the one that arrived is the one that gives way.
138
+ */
139
+ export declare function weldShortWalls(floor: PlanFloor, minLength?: number): PlanEdit<{
140
+ weldedWallIds: string[];
141
+ }>;
97
142
  export declare function removeWall(floor: PlanFloor, wallId: string, opts?: {
98
143
  pruneOrphanNodes?: boolean;
99
144
  dissolveCollinear?: boolean;
@@ -0,0 +1,36 @@
1
+ import { PlanFloor } from './floorPlan.types';
2
+ export type PlanMeshPart = 'wall' | 'floor';
3
+ export interface PlanMeshGroup {
4
+ part: PlanMeshPart;
5
+ /** Offset into `indices`, and how many of them. Draw ranges, or assign materials. */
6
+ start: number;
7
+ count: number;
8
+ }
9
+ export interface PlanMesh {
10
+ /** World-space xyz triples, in millimetres. */
11
+ positions: Float32Array;
12
+ /** Unit face normals, one per vertex. */
13
+ normals: Float32Array;
14
+ indices: Uint32Array;
15
+ groups: PlanMeshGroup[];
16
+ }
17
+ export interface BuildPlanMeshOptions {
18
+ /** Storey height for walls that do not carry their own. Defaults to the floor's. */
19
+ wallHeight?: number;
20
+ /** Lay a slab under each room. Default true. */
21
+ includeFloorSlab?: boolean;
22
+ /** Millimetres. The slab hangs below the floor level. Default 200. */
23
+ slabThickness?: number;
24
+ /**
25
+ * Lift everything by the floor's own elevation, so several storeys stack into one
26
+ * scene. Default true.
27
+ */
28
+ applyElevation?: boolean;
29
+ }
30
+ /**
31
+ * Triangles for one floor: every wall, with its openings cut out, and a slab per room.
32
+ *
33
+ * Call it per storey and concatenate to build a whole building; each floor already knows
34
+ * its own elevation.
35
+ */
36
+ export declare function buildPlanMesh(floor: PlanFloor, options?: BuildPlanMeshOptions): PlanMesh;
@@ -0,0 +1,11 @@
1
+ import { PlanPoint } from './floorPlan.types';
2
+ /**
3
+ * Triangulates a ring and its holes into index triples over the returned vertex list.
4
+ *
5
+ * The vertices come back as well as the indices because bridging a hole duplicates the
6
+ * two vertices it joins, so the caller cannot simply index into what it passed in.
7
+ */
8
+ export declare function triangulate(outer: readonly PlanPoint[], holes?: readonly (readonly PlanPoint[])[]): {
9
+ vertices: PlanPoint[];
10
+ indices: number[];
11
+ };
@@ -16,13 +16,15 @@ export { commitDerivation, derivePlan, pruneUnmatchedRooms, roomAtPoint } from '
16
16
  export type { CommitDerivationOptions } from './floorPlan.rooms';
17
17
  export { HIT_PX, HIT_PX_COARSE, hitTest, selectionsEqual, worldTolerances } from './floorPlan.hit';
18
18
  export type { HitTestOptions, HitTolerances } from './floorPlan.hit';
19
- export { DEFAULT_WALL_ATTRS, addOpening, addRect, addWall, cleanupPlan, dissolveNode, mergeNodes, moveNode, removeOpening, removeWall, scalePlan, splitWall, transformPlan, updateOpening, validatePlan, wallLength, } from './floorPlan.graph';
20
- export type { DissolveNodeOptions, ResolveNodeResult, SplitWallResult, WallAttrs, } from './floorPlan.graph';
19
+ export { DEFAULT_WALL_ATTRS, addOpening, addRect, addWall, cleanupPlan, detachWall, dissolveNode, mergeNodes, moveNode, removeOpening, removeWall, scalePlan, splitWall, transformPlan, updateOpening, validatePlan, wallLength, weldShortWalls, } from './floorPlan.graph';
20
+ export type { DetachWallResult, DissolveNodeOptions, ResolveNodeResult, SplitWallResult, WallAttrs, } from './floorPlan.graph';
21
21
  export { DEFAULT_SNAP_OPTIONS, SnapKind, clientToWorld, resolveSnap, screenScale } from './floorPlan.snap';
22
22
  export type { SnapContext, SnapGuide, SnapOptions, SnapResult } from './floorPlan.snap';
23
23
  export { buildWallPaths, openingGeometry, wallCentrelineSpans } from './floorPlan.path';
24
24
  export type { BuildWallPathsOptions, WallCapLine, WallCapReason, WallPathGroup, WallPathResult, } from './floorPlan.path';
25
25
  export { PLAN_FORMAT_VERSION, planFromJson, planToJson } from './floorPlan.json';
26
26
  export type { PlanParseResult } from './floorPlan.json';
27
+ export { buildPlanMesh } from './floorPlan.mesh';
28
+ export type { BuildPlanMeshOptions, PlanMesh, PlanMeshGroup, PlanMeshPart } from './floorPlan.mesh';
27
29
  export { planFromLegacySvg } from './legacySvg';
28
30
  export type { LegacyImportOptions } from './legacySvg';
package/docs/FloorPlan.md CHANGED
@@ -52,6 +52,8 @@ Every operation is pure and returns `PlanEdit<T>` = `{ floor, result, issues }`,
52
52
  | `splitWall(floor, wallId, at)` | Cuts a wall at a point or 0..1 parameter; redistributes its openings |
53
53
  | `mergeNodes(floor, keepId, removeId)` | Welds two nodes, deduplicating the walls that collapse together |
54
54
  | `dissolveNode(floor, nodeId)` | Rejoins two collinear same-construction walls; the inverse of `splitWall` |
55
+ | `detachWall(floor, wallId, delta)` | Pulls a wall off its shared corners and moves it by `delta`, bridging each old corner to the new one |
56
+ | `weldShortWalls(floor)` | Collapses every wall shorter than `MIN_WALL_LENGTH` by merging its two ends, so a squashed wall never leaves two corners stacked on one point |
55
57
  | `removeWall(floor, wallId, opts?)` | Removes a wall, its openings, and any node left orphaned |
56
58
  | `moveNode` / `transformPlan` / `scalePlan` | Move one node, translate everything, or rescale everything |
57
59
  | `addOpening` / `updateOpening` / `removeOpening` | Openings, clamped to fit their wall |
@@ -207,7 +209,43 @@ Feeding every legacy polygon edge through `addWall` is what collapses the old du
207
209
 
208
210
  ## Ready for 3D
209
211
 
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.
212
+ `buildPlanMesh(floor, options?)` turns a storey into triangles: wall solids with their openings cut out, and a slab under each room.
213
+
214
+ ```ts
215
+ import { buildPlanMesh } from '@ahrowe/ui';
216
+
217
+ const mesh = buildPlanMesh(plan.floors[0]);
218
+ // { positions: Float32Array, normals: Float32Array, indices: Uint32Array, groups }
219
+
220
+ // three.js, for example — no adapter needed
221
+ const g = new THREE.BufferGeometry();
222
+ g.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3));
223
+ g.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3));
224
+ g.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
225
+ ```
226
+
227
+ There is no renderer and no dependency: plain arrays are what every engine takes, so this feeds three.js, Babylon, a glTF writer or your own rasteriser without the package having an opinion. Call it per storey and concatenate for a whole building; each floor already knows its own `elevation`.
228
+
229
+ **Axes.** Plan x becomes world x, plan y becomes world **z**, and world y is up. A plan is drawn in SVG's y-down space, so that mapping is a handedness flip — faces are wound here to suit, rather than inheriting the 2D rings' winding.
230
+
231
+ **Openings are cut by splitting the wall, not by subtracting solids.** Every opening is an axis-aligned rectangle in its wall's own plane, so the wall falls into at most three prisms: under the sill, over the head, and the full-height stretches between openings. Exact, and no CSG library. A window leaves its upstand and lintel behind, which is what `sill` and `head` are carried for.
232
+
233
+ **Storeys stack wall to wall.** `elevation` is the *finished floor level*, so a slab hangs below it and the walls start at the slab's underside — they clothe the floor plate the way a facade does, rather than stopping at it. A wall stays exactly `floor.height` tall, one storey's walls end precisely where the next storey's begin, and the slab sits inside them instead of showing as a band between the storeys. Build the walls from the level itself and their top face ends up coplanar with the slab above, which z-fights.
234
+
235
+ A wall also drops a millimetre past the slab rather than landing flush on its underside. That is not arbitrary: a wall overlaps the slab it stands over, because the slab reaches the wall centrelines and the wall straddles them, so sharing a plane would leave two downward faces fighting for the same pixels and speckle the whole underside of the building. Wall heights and floor-to-floor spacing are unaffected.
236
+
237
+ `groups` marks which triangles are wall and which are floor, so the two can take different materials in one draw.
238
+
239
+ | Option | Default | |
240
+ |---|---|---|
241
+ | `wallHeight` | the floor's `height` | For walls with no `height` of their own |
242
+ | `includeFloorSlab` | `true` | Lay a slab under each room |
243
+ | `slabThickness` | `200` | Millimetres, hanging below floor level |
244
+ | `applyElevation` | `true` | Lift by the floor's own elevation, so storeys stack |
245
+
246
+ **Corners are mitred.** Two walls meeting at a corner both stop at the shared node, so left square they leave a gap of half a thickness on the outside of the turn — a notch cut through the full height of the wall. Their footprints are extended to where their edges actually cross instead, which is the same join the 2D renderer makes.
247
+
248
+ An end stays square where there is no single corner to mitre to: a junction of three or more walls, a change of thickness across the node, an angle acute enough to send the mitre running away, and the reveals of an opening, which are square faces in their own right. Walls overlap slightly at those joints, which is invisible on an opaque solid but would show in a cutaway or through glass, and would double-count in a volume taken straight off the triangles.
211
249
 
212
250
  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
251
 
@@ -47,6 +47,19 @@ const [plan, setPlan] = useState<FloorPlan>(() => emptyFloorPlan());
47
47
  - an opening slides along its wall
48
48
  - a room moves every corner it owns
49
49
 
50
+ **Hold Ctrl/Cmd while dragging a wall to detach it instead.** The wall comes off the
51
+ corners it shares, the neighbours keep their ends, and a connecting wall grows between
52
+ each old corner and the new one. Use it when a partition joins the wall being moved: a
53
+ plain drag takes the partition's end along with it, a detach drag leaves it where it is.
54
+
55
+ Pulling a wall off a plain corner is deliberately a no-op on the drawing: the connector
56
+ continues the wall that was already there, so the seam it leaves is straight and is
57
+ dissolved on the spot. Nothing is detached until the wall has moved at least one grid
58
+ step, so a Ctrl-click on a wall is still just a selection toggle. Push the wall back onto
59
+ the line it came off and the corners weld back together, whether that happens in the same
60
+ drag or a later one — the corner that survives is the one that was already there.
61
+ Grabbing a selected wall's midpoint handle always adds a corner, Ctrl or not.
62
+
50
63
  ## Measuring without drawing
51
64
 
52
65
  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.
@@ -127,6 +140,7 @@ The `snap`, `angleLock` and `showGrid` props seed the toggles; changing one rese
127
140
  | `S` / `L` / `G` | Toggle snapping / angle lock / grid |
128
141
  | `Ctrl/Cmd+A` | Select everything |
129
142
  | `Ctrl/Cmd`-click | Add or remove one thing from the selection |
143
+ | `Ctrl/Cmd`-drag a wall | Detach it from its corners instead of dragging them along |
130
144
  | `Shift` (hold) | Momentarily invert angle lock |
131
145
  | `Alt` (hold) | Momentarily invert snapping |
132
146
  | `Esc` | Cancel the wall chain, or clear the selection |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahrowe/ui",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -101,6 +101,7 @@
101
101
  "@types/react": "^19.2.15",
102
102
  "@types/react-dom": "^19.2.3",
103
103
  "@types/react-syntax-highlighter": "^15.5.13",
104
+ "@types/three": "^0.186.0",
104
105
  "@vitejs/plugin-react": "^6.0.2",
105
106
  "@vitest/ui": "^4.1.7",
106
107
  "eslint": "^9.39.4",
@@ -118,6 +119,7 @@
118
119
  "react-dom": "^19.2.6",
119
120
  "react-element-to-jsx-string": "^17.0.1",
120
121
  "react-syntax-highlighter": "^16.1.1",
122
+ "three": "^0.186.0",
121
123
  "tsx": "^4.22.3",
122
124
  "typescript": "^6.0.3",
123
125
  "typescript-eslint": "^8.60.0",