Skip to content

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 forNot for
Honouring a human lock: fixed rings come back verbatim in every optionUnits 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 binsCirculation 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 haveA 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 and plannableArea drops. 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 a RoomCell with fixed: true and ring as given (simplified, not reoriented). Its cell is scored like any other, so a bad lock shows up in score. 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. plannableArea still 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 into unplaced before 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 in unit² (~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 drives score.minWidthRatio. Default 2400 mm expressed in unit (~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 in unit (~3 ft).
  • gridSnap?: number — Snap cut positions to a real-world grid, in unit. 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 and score.areaRatio drops. Aligned walls, less exact areas.
  • weights?: RoomsWeights
  • maxIterations?: number — Convenience alias for budget.maxIterations; budget wins.
  • 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, in unit². 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 near targetArea × plannableArea / Σtargets. When the targets do not sum to the plannable area every room scales by the same factor and score.areaRatio reports 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's minRoomWidth.
  • 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), and score.adjacencyRatio counts the pairs that ended up sharing at least doorWidth of 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 in rooms.
  • 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?: number
  • aspectWeight?: number
  • adjacencyWeight?: number
  • minWidthWeight?: 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_VERSION
  • unit: 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: RoomsScore
  • cells: 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 × mean of the per-cell aspect verdicts.
  • adjacencyRatio: number — Requested pairs that share at least doorWidth of 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: string
  • label: string | null
  • ring: PlanPoint[] — The cell outline, open, in world plan coordinates.
  • area: number
  • targetArea: number
  • achievedRatio: number — area / targetArea. 1 is exactly on target.
  • minWidth: number — Narrow dimension of the cell's minimum-width oriented box.
  • aspectRatio: number — Long dimension over minWidth.
  • bbox: PlanBox
  • centroid: PlanPoint
  • fixed: boolean — True when the caller locked this ring through fixed.

UnplacedRoom ​

  • roomId: string
  • targetArea: number
  • reason: 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?: RoomsWeights
  • doorWidth?: number — Default 900 mm expressed in unit.
  • minRoomWidth?: number — Default 2400 mm expressed in unit.

RoomsScoreOutput ​

  • contractVersion: typeof ROOMS_CONTRACT_VERSION
  • unit: 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:
  • Returns: RoomsOutput
  • Throws: InvalidUnitError — on a missing or unknown unit.
  • 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 a fixed entry 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:
  • Returns: RoomsScoreOutput
  • Throws: InvalidUnitError — on a missing or unknown unit.
  • 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 ​

ConditionResult
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