Skip to content

pack.corridorRuns ​

Lay typed cells along corridor paths.

One call. Give it corridor centrelines, the buildable envelope and a library of cell kinds; it builds a band of a given depth on each side of every path, carves those bands to the envelope and tiles them with cells drawn from the library. Out come plan polygons in the caller's unit and an audit of how each band was segmented.

ENGINE vs CONTROLS. The engine is the pipeline: frame the path → band each side → carve to the envelope → fill each free segment with typed cells → snap to a bay → handle the bends → resolve the leftover → audit. The controls (composition, reservations, ordering, cornerPolicy, residuePolicy, baySnap, scoring) are all optional and every default means "no opinion": no target mix, nothing reserved, kinds in the order given, bends left to the mitred band, leftover stretched into the cells that can take it and otherwise left empty and reported, and count vectors ranked coverage → mix → count. Apartments, hotel keys, ward bays, cellular offices and lab benches are this one call with different control values.

Pure module: no host, no DOM, no clock, no RNG — the one stochastic step, the tie-break between equally good count vectors, is seeded. All lengths are in input.unit (areas in its square); the module computes natively on the caller's numbers and only its own defaults and tolerances, defined in millimetres, are scaled to that unit. Plan coordinates are x/z; Y is up and never appears.

ts
// plugin worker
import { kindFrontages, corridorBands, corridorRuns } from "@snaptrude/plugin-toolkit/pack-corridor-runs";

// agent run_worker
modules.pack.corridorRuns.kindFrontages(...)
modules.pack.corridorRuns.corridorBands(...)
modules.pack.corridorRuns.corridorRuns(...)

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
Typed cells strung along a spine: apartments, hotel keys, ward bays, cellular offices, lab benches, lockersArea-target rooms with no spine — see pack.rooms; this takes frontage and depth, and the envelope decides the depth
Holding a mix (composition) or an exact sequence, with cores and stairs reserved at a spacingReserving the corridor strip itself — see pack.corridorBand

Inputs and outputs ​

The shapes every function below speaks. Each appears once; a field's note says what changing it does to the result.

CellKind ​

One kind of cell the fill may draw.

  • kind: string — Label carried straight through to cells[].kind. Must be unique.
  • width?: number — Exact frontage along the path. Give this or widthRange.
  • widthRange?: [number, number] — [min, max] frontage along the path — the elastic band the fill spends. A wide band lets the fill hit a segment length exactly; a tight one forces leftover.
  • depth?: number — Cell depth. Defaults to (and is capped at) the band's depth. A shallower kind draws a shallower polygon against the same corridor; its frontage does not change.

KindFrontage ​

What one kind's frontage band becomes once the bay snap has had its say.

  • kind: string
  • minWidth: number
  • maxWidth: number
  • depth: number
  • offGrid: boolean

CorridorRunsInput ​

  • version?: number — Refused when present and not 2.
  • unit: LengthUnit — The length unit every length in this input is in and every length in the output is written in. Areas are in its square. Validated first.
  • paths: CorridorPath[]
  • envelope: Envelope
  • corridorWidth: number — Clear corridor width; each band starts half this off the centreline.
  • bandDepth: BandDepth
  • cellKinds: CellKind[]
  • composition?: CompositionTarget[] — Target share per kind. Absent = fill by fit alone, every kind offered. Once given, the kinds open to free fill are exactly those with a positive share; a kind with shareRatio: 0 or missing from the list is seated only by reservations and corner folds.
  • reservations?: Reservation[] — Spans that must hold a given kind or stay empty.
  • ordering?: Ordering
  • cornerPolicy?: CornerPolicy
  • residuePolicy?: ResiduePolicy
  • baySnap?: number — Structural bay. Frontages quantise to whole bays measured from each segment's start, so interior walls land on one lattice both sides share. Cell count drops slightly, the tail cell of a segment takes the sub-bay remainder, and a kind whose band holds no whole bay ships offGrid at its true frontage.
  • minBandDepth?: number — Shallowest band worth building. A stretch of path where the envelope leaves less than this is not buildable at all, rather than carving the whole band down to it. Default 1000 mm, expressed in unit.
  • seed?: number — Seeds the tie-break between equally good count vectors. Default 1.
  • scoring?: Scoring — How count vectors are ranked. Default coverage → mix → count, strict.
  • budget?: Budget

CorridorPath ​

One corridor centreline.

  • id: string — Caller's handle; comes back on every cell as pathId. Must be unique.
  • pts: PlanPoint[] — Centreline in plan, in unit. Repeat the first point to close a ring.
  • bandDepth?: BandDepth — Overrides the input-level band depth for this path. Lets one wing be shallower, or single-loaded, while the rest of the network keeps the default.
  • corridorWidth?: number — Overrides the input-level corridor width for this path.

CompositionTarget ​

Target share of the free-fill cells for one kind. Weights need not sum to 1.

A positive share is a PREFERENCE, not a quota. The fill ranks the count vectors that fit a segment by the scoring tiers — coverage first by default — and the mix only decides among vectors that cover the segment equally well. A kind that tiles a segment more tightly can therefore overshoot its share; audit.composition reports what was achieved and by how much it drifted. Trade frontage for mix with scoring.mixTolerance, or put mix first in scoring.priority.

shareRatio: 0 is the one HARD rule: the kind never enters free fill and is placed only where a reservations[] entry or cornerPolicy.kind names it. When a composition is given at all, a kind it does not mention is treated the same way, so the kinds you offer the packer are exactly the kinds with a positive share. Without a composition every kind is offered.

  • kind: string
  • shareRatio: number — > 0: preferred share. 0: never free-filled. A positive share steers the mix tier only — coverage still wins first unless scoring says otherwise, so a kind that tiles tighter can overshoot it. Zero keeps the kind out of free fill entirely; it appears only where a reservation or corner fold names it, and the audit shows freeFill: false.

Reservation ​

A span along a path the fill may not touch.

  • pathId: string
  • side?: BandSide — Both sides when absent.
  • d0: number
  • d1: number
  • kind: string | null — The kind to seat there, or null to keep the span clear.

Ordering ​

  • pattern?: OrderingPattern — Defaults to sequence when sequence is given, else grouped.
  • sequence?: string[] — Repeating pattern of kind names for pattern: "sequence".
  • maxSameInARow?: number | null — Longest allowed run of the same kind. null (default) = no limit. Count vectors whose emitted order would break it are heavily penalised, so alternation wins unless nothing else tiles the segment — in which case the run ships with a note.

CornerPolicy ​

What happens where a corridor path bends.

On the INSIDE of a bend the two arms' bands would overlap, so the fill casts a fold shadow on the concave band only, centred on the vertex, of half-width

(corridorWidth / 2 + bandDepth) × tan(turn / 2) + clearance

A 90° bend with an 1800 corridor and a 9000 band (in mm) shadows 9900 each way, so the fold is 19800 of that band's frontage. Bends gentler than minTurnDeg are not folds. Bends at or past sharpTurnDeg are always left empty with a sharp-turn note, whatever action says — the wedge cannot hold a sane cell. Everything outside the fold is filled as usual, so both arms start clear of it.

action decides the fold: ignore (default) lets the band mitre through and a cell may straddle the vertex; cell seats ONE cell of kind in it — a lift lobby or stair at an L — with isCorner: true; reserved is cell plus isReserved: true; empty puts the fold in unfilled with reason corner-empty. cell / reserved are the way to seat a service kind at a bend: the fold is exactly the frontage the bend consumes, whereas a reservations[] span there is a guess that usually costs a cell per arm.

The OUTSIDE of a bend is not governed here. The convex band's two arms mitre, and the one cell spanning the vertex absorbs the whole wedge — about (corridorWidth / 2 + bandDepth)² × tan(turn / 2) of extra area, 98 m² for a 90° bend on a 9000 mm band. If that inflates a unit past what its kind should be, chamfer the ENVELOPE at that corner: the carve trims that one cell and leaves the band depth, the count and every other cell alone (a chamfer of about the band depth halves the wedge). A reservation of a larger kind over the vertex span is the other honest answer. Read cells[].area at the vertex to see the wedge: the module emits no note for it.

  • action?: CornerAction — Default ignore. On the concave band of every bend past minTurnDeg, a fold of half-width (corridorWidth/2 + depth) × tan(turn/2) each side of the vertex is ignored (the band mitres through, a cell may straddle it), filled by one cell of kind (isCorner), reserved (that plus isReserved) or left empty (unfilled, reason corner-empty). The convex band is never touched: its vertex cell keeps the mitre wedge, about (corridorWidth/2 + depth)² × tan(turn/2) of extra area — chamfer the envelope there to trim it. A closed ring (pts ends where it starts) has a bend at its seam too, and it counts: a rectangle ring given from one of its corners folds four corners, not three. The seam's fold is ONE cell wrapped through d = 0 — [d0, d1] at the head of the path plus seamSpan at its tail — and only a band the envelope left whole can hold it.
  • kind?: string — Kind to seat in the fold for cell / reserved. Usually a kind with shareRatio: 0. Defaults to the first kind whose frontage range fits the fold, else the widest kind. The fold cell takes this kind at the fold's exact frontage whatever the kind's range says; the fill on both arms starts clear of it, so no other cell is lost to the bend.
  • minTurnDeg?: number — Bends gentler than this are not folds at all. Default 30.
  • sharpTurnDeg?: number — At or past this the fold is left empty whatever action says. Default 127. The fold goes to unfilled with reason sharp-turn and a note; the arms still fill up to it.
  • clearance?: number — Extra frontage the fold consumes each side, past its geometric shadow, in unit. Default 0. Widens the fold cell (or the empty span) by this much on both arms; the way to give a corner lobby its lift bank.

ResiduePolicy ​

  • order?: ResidueAction[] — Preference order. Default ["stretch", "empty"].
  • kind?: string — Kind stamped on a labelled leftover cell. Default "residue".
  • minLabelWidth?: number — Leftover shorter than this is never labelled. Default 500 mm, expressed in unit.

Scoring ​

How the fill ranks the count vectors that fit a segment. The tiers are strict: a later tier only speaks when every earlier one is tied. Above all of them sits ordering.maxSameInARow, which is a rule, not a tier; below them, without a composition, the order of cellKinds and then the seed.

  • priority?: ScoringTier[] — Tier order, each of the three exactly once. Default ["coverage", "mix", "count"]: close the gap, then hit the mix, then prefer more (smaller) cells. ["mix", "coverage", "count"] hits the requested shares even where that leaves frontage unfilled; ["count", …] maximises yield and lets both gap and mix follow.
  • mixTolerance?: number — Blends mix into the coverage tier: the frontage, in unit, the caller gives up for one full unit of mix deviation. Default 0 (strict). At 500 (mm), a vector leaving 400 mm more gap but drifting 1.0 less still wins the coverage tier. Has no effect once mix is ahead of coverage in priority.

Budget ​

  • maxSolverNodes?: number — Count-solver node cap per segment. Default 200000.
  • maxCellsPerSegment?: number — Cap on cells the fill may put in one segment. Default 64.

CorridorBandsOutput ​

  • contractVersion: typeof CORRIDOR_RUNS_CONTRACT_VERSION
  • unit: LengthUnit — Echo of input.unit.
  • bands: PackedBand[]
  • notes: PackNote[]

PackedBand ​

  • pathId: string
  • side: BandSide
  • d0: number — One buildable arc span. A hole in the envelope gives a side several bands.
  • d1: number
  • requestedDepth: number
  • depth: number — What the envelope actually allowed across the whole span.
  • carved: boolean — True when depth is less than requestedDepth.
  • ring: PlanPoint[]
  • area: number

PackNote ​

A diagnostic. A path can be packed and still carry notes.

  • pathId: string | null
  • code: string
  • message: string

CorridorRunsOutput ​

  • contractVersion: typeof CORRIDOR_RUNS_CONTRACT_VERSION
  • unit: LengthUnit — Echo of input.unit: every length below is in it, every area in its square.
  • cells: PackedCell[]
  • bands: PackedBand[]
  • unfilled: UnfilledSpan[]
  • audit: CorridorRunsAudit
  • notes: PackNote[]

PackedCell ​

  • id: string — c1, c2, … in emission order.
  • pathId: string
  • side: BandSide
  • kind: string
  • d0: number — Span along the path, arc length from the path's start.
  • d1: number
  • ring: PlanPoint[] — Plan outline, in unit.
  • area: number — In unit².
  • depth: number
  • isResidue: boolean — True when this cell is leftover the fill could not turn into a kind.
  • isReserved: boolean — True when a reservation, or a corner policy of reserved, seated it.
  • isCorner: boolean — True when it owns the inside of a bend.
  • offGrid: boolean — True when the bay snap could not give its kind a whole bay.
  • seamSpan?: Span — Only on the corner cell at the seam of a closed ring: the fold's second arm, at the far end of the path. The cell occupies [d0, d1] AND this span — one cell, one ring, wrapped through d = 0.

UnfilledSpan ​

A span of a path's side that carries no cell, and why. Anything under a millimetre wide is float residue rather than ground: it is folded into the neighbouring cell's span instead of being reported here.

  • pathId: string
  • side: BandSide
  • d0: number
  • d1: number
  • reason: UnfilledReason

CorridorRunsAudit ​

  • perPath: PathAudit[]
  • slots: number — Σ perPath[].slots — equals cells.length.
  • composition: CompositionAudit[]

PathAudit ​

  • pathId: string
  • length: number
  • segments: SegmentAudit[]
  • slots: number — Σ segments[].slots — the number of cells on this path.

SegmentAudit ​

One span of a band and how many cells it holds. Every cell belongs to exactly one.

  • side: BandSide
  • d0: number
  • d1: number
  • slots: number
  • decidedBy?: DecidedBy — Absent on forced spans (reservations, corner folds) and on segments no kind fit.

CompositionAudit ​

  • kind: string
  • count: number
  • achievedRatio: number — Share of all non-residue cells this kind took.
  • targetRatio: number | null — Normalised target, or null when the caller gave no opinion for this kind.
  • deviationRatio: number | null — achievedRatio − targetRatio, or null without a target.
  • freeFill: boolean — False when the kind was kept out of free fill (zero or absent share) and could only be seated by a reservation or corner fold.

Functions ​

kindFrontages(cellKinds, bandDepth, baySnap, unit) ​

The frontage band each kind actually lays, after the bay snap. Exported so a caller can size a segment with the same numbers the fill uses. All lengths are in unit (default "mm"), which also scales the module's tolerance.

  • Parameters:
    • cellKinds: CellKind[]
    • bandDepth: number
    • baySnap?: number
    • unit?: LengthUnit — Default "mm".
  • Returns: KindFrontage[]
  • Throws: InvalidInputError — when a kind has no usable width.

corridorBands(input) ​

The buildable strips alone: frame every path, band each side, carve to the envelope. The first half of corridorRuns, for a caller that wants to know how much ground there is before deciding what to put on it.


corridorRuns(input) ​

Lay typed cells along corridor paths. Cells never overlap and never leave the envelope; every span of every side is either a cell or an unfilled entry, so [0, pathLength] is always fully accounted for — counting a seam cell's seamSpan, and with sub-millimetre residue folded into the cell beside it rather than reported. audit.slots equals cells.length. A path wholly outside the envelope yields no cells and a note saying so.

Examples ​

Cellular offices along a spine (mm) — fill by fit only (both)

ts
return corridorRuns({
  unit: "mm",
  paths: [{ id: "spine", pts: spine }],
  envelope: { ring: envelopeRing },
  corridorWidth: 1800,
  bandDepth: 6000,
  cellKinds: [{ kind: "office", widthRange: [3000, 4500], depth: 6000 }],
  baySnap: 1500
});

Hotel floor: a target mix, cores reserved at both ends (both)

ts
return corridorRuns({
  ...base,
  cellKinds: [
    { kind: "king", widthRange: [3600, 4200], depth: 8000 },
    { kind: "twin", widthRange: [3900, 4500], depth: 8000 }
  ],
  composition: [
    { kind: "king", shareRatio: 0.6 },
    { kind: "twin", shareRatio: 0.4 }
  ],
  reservations: [
    {
      pathId: base.paths[0].id,
      side: "left",
      d0: 0,
      d1: 3000,
      kind: null
    }
  ],
  ordering: { pattern: "alternate", maxSameInARow: 3 },
  baySnap: 3600
});

L-shaped wing (mm): apartments both sides, the lift lobby seated at the bend (both)

ts
return corridorRuns({
  unit: "mm",
  paths: [{ id: "wing", pts: lSpine }],
  envelope: { ring: envelopeRing },
  corridorWidth: 1800,
  bandDepth: 9000,
  cellKinds: [
    { kind: "1BR", widthRange: [6500, 7500], depth: 9000 },
    { kind: "2BR", widthRange: [9500, 11000], depth: 9000 },
    // Service kinds get a zero share: the fill never places them on its own.
    { kind: "lobby", widthRange: [14000, 22000], depth: 9000 },
    { kind: "stair", width: 4000, depth: 9000 }
  ],
  composition: [
    { kind: "1BR", shareRatio: 0.6 },
    { kind: "2BR", shareRatio: 0.4 },
    { kind: "lobby", shareRatio: 0 },
    { kind: "stair", shareRatio: 0 }
  ],
  // The inside of the bend is exactly the frontage the bend consumes, so the
  // lobby goes there by policy rather than by guessing a reservation span.
  cornerPolicy: { action: "cell", kind: "lobby" },
  // Stairs along the arms are reservations at a spacing.
  reservations: [
    { pathId: "wing", side: "right", d0: 2000, d1: 6000, kind: "stair" },
    { pathId: "wing", side: "right", d0: 50000, d1: 54000, kind: "stair" }
  ],
  ordering: { pattern: "alternate" }
});

Single-loaded lab bench row — depth 0 on one side (plugin)

ts
return corridorRuns({
  ...base,
  bandDepth: { left: 9000, right: 0 },
  cellKinds: [{ kind: "lab", widthRange: [3300, 3600], depth: 9000 }],
  baySnap: 3300
});

Compare two corridor widths and keep the one that seats more cells (agent)

ts
const tried = [1800, 2400].map((corridorWidth) => corridorRuns({ ...base, corridorWidth }));
return tried.reduce((a, b) => (b.cells.length > a.cells.length ? b : a));

How much frontage each kind needs, before packing anything (agent)

ts
const depth =
  typeof base.bandDepth === "number"
    ? base.bandDepth
    : (base.bandDepth.left ?? base.bandDepth.right ?? 0);
return kindFrontages(base.cellKinds, depth, base.baySnap);

Errors ​

ConditionResult
kindFrontages() — a kind has no usable width.throws InvalidInputError
corridorRuns() — a malformed input, or a control naming a path or kind that does not exist.throws InvalidInputError