Appearance
pack.rooms
Subdivide a boundary into one cell per room, areas proportional to targets, and return N ranked candidates.
Two calls. rooms packs a brief into a boundary: it folds the holes and the kept-clear voids into one ring, subdivides the ring's frame recursively at target-area ratios, clips every leaf back to the ring, then runs a seeded local-improvement pass and ranks what it found. score re-scores an existing pool of cell rings under different weights, so a caller can reorder options without repacking.
The module knows nothing about rooms as such. Floor plates into offices, a site parcel into plots, a warehouse into zones, a shelf into bins: same call, different targets. What it does not do is circulation design, doors, walls, curved boundaries or multiple storeys.
Pure module: no host, no DOM, no clock, no RNG (all variety comes from seed). Every length is in input.unit and every area in its square; the algorithms compute natively on the caller's numbers and only the module's own defaults and tolerances are scaled by the unit. Plan coordinates are x/z; Y is up and never appears.
ts
// plugin worker
import { rooms, score } from "@snaptrude/plugin-toolkit/pack-rooms";
// agent run_worker
modules.pack.rooms.rooms(...)
modules.pack.rooms.score(...)Every input carries unit ("mm" | "cm" | "m" | "ft" | "in"). All lengths in that call — input and output — are in it; angles are degrees, areas are that unit squared.
When to use it
| Use it for | Not for |
|---|---|
Honouring a human lock: fixed rings come back verbatim in every option | Units strung along a corridor spine — see pack.corridorRuns |
| Turning an area brief into cells: floor plates into offices, a parcel into plots, a warehouse into zones, a shelf into bins | Circulation design, doors, walls, curved boundaries or several storeys at once |
| Generating many ranked candidates from one boundary and re-picking on a criterion the scorer does not have | A step-by-step layout the user steers in the UI |
| Packing around fixed holes and kept-clear voids (cores, atria, a reserved corridor band) |
Inputs and outputs
The shapes every function below speaks. Each appears once; a field's note says what changing it does to the result.
RoomsInput
unit:LengthUnit— The unit every length in this input is in; areas are in its square.boundary:PlanPoint[]— Outer ring, open (no repeated last point), simple, counter-clockwise or not.holes?:PlanPoint[][]— Cores, shafts, courtyards: area that is not plannable. Bridged into the boundary, so cells wrap around them andplannableAreadrops. Must lie strictly inside the boundary.voids?:PlanPoint[][]— Regions to keep clear — a corridor band, a stair, a reserved bay. Handled exactly like a hole; kept a separate field because the intent differs and a caller usually wants to report on it. Subtracted before subdivision, so every option has it. Must lie strictly inside the boundary; inset a spine corridor that would otherwise touch the perimeter.rooms:RoomSpec[]fixed?:FixedRoom[]— Rooms the human has already placed and locked. The lock is a guarantee, not a hint: the ring comes back unchanged in every option. Each locked ring is carved out of the buildable area exactly like a hole, so the packing runs around it and never covers it; the locked room is dropped from the packing order and emitted as aRoomCellwithfixed: trueandringas given (simplified, not reoriented). Its cell is scored like any other, so a bad lock shows up inscore. A locked ring that overlaps a hole or a void is still emitted — the lock wins over the cut-out — and the option carries a note saying so.plannableAreastill means boundary minus holes minus voids, locks included, so the cells (locked and packed) tile it.optionCount?:number— How many ranked candidates to return. The output is truncated to this many distinct options. Distinct means different cell centroids; asking for more than the search finds returns fewer. Default 3.seed?:number— Seeds every choice: which orders are tried, the coin flip between two near-equal cut axes, which pairs the improvement pass swaps. Same seed and same input give byte-identical options; a different seed is the cheapest way to a different pool. Default 777.minRoomArea?:number— Below this share, a room is dropped intounplacedbefore packing rather than given a cell nobody can use. Rooms are dropped smallest target first until every kept room's proportional share clears this. Default 5 950 000 mm² expressed inunit² (~64 ft², the Space Planner floor).minRoomWidth?:number— Narrowest a cell may get before it counts against the option. Breaks a tie between two cut axes towards the one that keeps both children above it, and drivesscore.minWidthRatio. Default 2400 mm expressed inunit(~8 ft).doorWidth?:number— Shortest shared wall that still counts as a door. Adjacency scoring only; two cells meeting over less than this are not neighbours. Default 900 mm expressed inunit(~3 ft).gridSnap?:number— Snap cut positions to a real-world grid, inunit. 0 is off. Every wall moves to the nearest grid line after the area bisection, so areas drift from target by up to half a grid cell times the wall length andscore.areaRatiodrops. Aligned walls, less exact areas.weights?:RoomsWeightsmaxIterations?:number— Convenience alias forbudget.maxIterations;budgetwins.budget?:RoomsBudget
RoomSpec
One room in the brief.
id:string— Caller's handle. Comes back on the cell and is the key adjacency uses.label?:string— Human name, echoed onto the cell.targetArea:number— Area target, inunit². Cuts land at target ratios, so this is the only field that decides how big a cell is. Each cut sits atΣtarget(left) / Σtarget(node), so a cell ends up neartargetArea × plannableArea / Σtargets. When the targets do not sum to the plannable area every room scales by the same factor andscore.areaRatioreports how even that spread is.minWidth?:number— Narrowest this room may get before it counts against the option. Ranking and the split-axis choice; never a hard refusal. Defaults to the input'sminRoomWidth.adjacency?:string[]— Ids this room wants to share a wall with. Two things. Adjacent rooms are ordered next to each other before subdivision, which usually makes them neighbours (geometry), andscore.adjacencyRatiocounts the pairs that ended up sharing at leastdoorWidthof wall (ranking). Never a hard constraint.maxAspectRatio?:number— Aspect ratio this room tolerates before the proportion score drops. Ranking only. Raise it for a corridor-like room so it stops dragging an option down. Defaults to 3.
FixedRoom
A room the human has locked: this exact ring, in every option.
roomId:string— Must name a room inrooms.ring:PlanPoint[]— The locked outline, open, world plan coordinates. Comes back verbatim.
RoomsWeights
Relative importance of the four score terms. Any weight may be 0 (drop the term) or negative (invert it).
areaWeight?:numberaspectWeight?:numberadjacencyWeight?:numberminWidthWeight?:number
RoomsBudget
Deterministic stop. There is no clock and no signal in a pure module.
maxIterations?:number— Hard cap on candidate layouts built. One layout is one iteration; the first one is always built, budget or not. Fewer iterations means fewer distinct options and a worse best-of, never a partial option.
RoomsOutput
contractVersion:typeof ROOMS_CONTRACT_VERSIONunit:LengthUnit— The input's unit, echoed: every length below is in it, every area in its square.options:RoomsOption[]— Ranked, best first. Distinct: no two options place cells the same way.boundaryArea:number— Area of the outer ring.plannableArea:number— Outer ring minus holes minus voids: what the cells tile. Locked rings are NOT subtracted — a locked cell is one of the cells that tile it.iterationsUsed:number— Candidate layouts built.budgetExhausted:boolean— True when the search stopped on the budget with work left to do.
RoomsOption
id:string—o1,o2, … best first.score:RoomsScorecells:RoomCell[]unplaced:UnplacedRoom[]notes:string[]— Provenance, one short line each: where the option came from, what bit.
RoomsScore
Every term is 0..1, higher better, before weighting.
areaRatio:number—min(fill) / max(fill),fill = share of area ÷ share of target.aspectRatio:number— Proportion:0.6 × worst + 0.4 × meanof the per-cell aspect verdicts.adjacencyRatio:number— Requested pairs that share at leastdoorWidthof wall, over all requested pairs.minWidthRatio:number— Cells at or above their minimum width, over all cells.totalRatio:number—Σ(term × weight) / Σ|weight|. What options are ranked on.
RoomCell
One room's cell in one option.
roomId:stringlabel:string | nullring:PlanPoint[]— The cell outline, open, in world plan coordinates.area:numbertargetArea:numberachievedRatio:number—area / targetArea. 1 is exactly on target.minWidth:number— Narrow dimension of the cell's minimum-width oriented box.aspectRatio:number— Long dimension overminWidth.bbox:PlanBoxcentroid:PlanPointfixed:boolean— True when the caller locked this ring throughfixed.
UnplacedRoom
roomId:stringtargetArea:numberreason:UnplacedReason
RoomsScoreInput
Input to score: rings only, no boundary needed.
unit:LengthUnit— The unit every ring and width in this input is in; targets are in its square.rooms:RoomSpec[]options:{ id: string; cells: { roomId: string; ring: PlanPoint[] }[] }[]weights?:RoomsWeightsdoorWidth?:number— Default 900 mm expressed inunit.minRoomWidth?:number— Default 2400 mm expressed inunit.
RoomsScoreOutput
contractVersion:typeof ROOMS_CONTRACT_VERSIONunit:LengthUnit— The input's unit, echoed.options:{ id: string; score: RoomsScore }[]— Re-ranked, best first. Ids are the caller's own.
Functions
rooms(input)
Packs a brief into a boundary and returns ranked candidate subdivisions.
Every room becomes one cell whose area is proportional to its target; cells tile the plannable area (boundary minus holes minus voids) and never leave it. Rooms that cannot get a usable cell come back in unplaced instead of overflowing. Every length is read and written in input.unit.
- Parameters:
input:RoomsInput
- Returns:
RoomsOutput - Throws:
InvalidUnitError— on a missing or unknownunit. - Throws:
InvalidBoundaryError— on a ring that is not simple, a hole or void that is not strictly inside the boundary, a locked ring outside the boundary, or two locked rings that overlap each other. - Throws:
InvalidRoomsError— on an empty brief, a non-positive target, a duplicate id, an adjacency reference to an unknown room, or afixedentry naming a room that is not in the brief.
Effect on output: options is ranked by score.totalRatio and cut to optionCount distinct entries. Determinism comes from seed alone.
score(input)
Re-scores and re-ranks a pool of options that already exist, under different weights. No packing, no boundary: the terms are all cell-relative. Rings and widths are read in input.unit.
- Parameters:
input:RoomsScoreInput
- Returns:
RoomsScoreOutput - Throws:
InvalidUnitError— on a missing or unknownunit. - Throws:
InvalidRoomsError— on an unusable brief.
Effect on output: the same options in a possibly different order, each with a fresh RoomsScore. Use it to answer "which of these would win if adjacency mattered more" without paying for another pack.
Examples
Pack a brief into the selected plate (both)
ts
return rooms({
unit,
boundary: plateRing,
rooms: brief,
seed: 42,
optionCount: 12
});Generate many, then shortlist on a criterion the scorer does not have (agent)
ts
const { options } = rooms({
unit,
boundary: plateRing,
rooms: brief,
seed: 7,
optionCount: 8
});
// A door's width of shared wall: 900 mm expressed in the plate's unit.
const doorWidth = constMm(900, unit);
const wet = new Set(wetRoomIds);
const clustered = options.filter((o) => {
const cells = o.cells.filter((c) => wet.has(c.roomId));
return cells.every((c) =>
cells.some((d) => d !== c && sharedEdgeLength(c.ring, d.ring, undefined, unit) > doorWidth)
);
});
return (clustered.length ? clustered : options)[0];Keep a core and an atrium out of the packing (both)
ts
return rooms({
unit,
boundary: plateRing,
holes: [atriumRing],
voids: [coreRing],
rooms: brief,
seed: 3,
optionCount: 6
});Re-rank an existing pool under different weights, without repacking (agent)
ts
const bare = pool.map((o) => ({
id: o.id,
cells: o.cells.map((c) => ({ roomId: c.roomId, ring: c.ring }))
}));
const adjacencyFirst = score({
unit,
rooms: brief,
options: bare,
weights: { adjacencyWeight: 1, areaWeight: 0.2, aspectWeight: 0.3 }
});
const areaFirst = score({
unit,
rooms: brief,
options: bare,
weights: { areaWeight: 1, adjacencyWeight: 0.1 }
});
return {
adjacencyWinner: adjacencyFirst.options[0].id,
areaWinner: areaFirst.options[0].id
};Keep the rooms the user locked and re-pack around them (both)
ts
const input: RoomsInput = {
unit,
boundary: plateRing,
rooms: brief,
// Every option comes back with these rings untouched, tinted `locked`.
fixed: locked,
seed: 42,
optionCount: 6
};
const out = rooms(input);
const index = Math.min(optionIndex, out.options.length - 1);
return out.options[index];Errors
| Condition | Result |
|---|---|
rooms() — a missing or unknown unit. | throws InvalidUnitError |
rooms() — a ring that is not simple, a hole or void that is not strictly inside the boundary, a locked ring outside the boundary, or two locked rings that overlap each other. | throws InvalidBoundaryError |
rooms() — an empty brief, a non-positive target, a duplicate id, an adjacency reference to an unknown room, or a fixed entry naming a room that is not in the brief. | throws InvalidRoomsError |
score() — a missing or unknown unit. | throws InvalidUnitError |
score() — an unusable brief. | throws InvalidRoomsError |
Related
pack.corridorRuns— cells strung along a spine instead of subdividedpack.corridorBand— reserve the circulation strip before packinggeom.polygon— the ring primitives the scorer uses