# 21 — Auto-imposition planning (`--pack`)

*(2.4.0)* Given a list of ordered items and the press sheets a shop stocks, `--pack` answers the
planning question that comes before imposition: which items gang together into which runs, on
which sheet, how many press sheets each run needs, where every copy sits, and where the
guillotine cuts go — priced, under several strategies at once. It is **layout only**: no artwork
is opened and no PDF is written (the exceptions are `"mode": "roll"` with `--die-pdf` and
`"mode": "nest"`, below). The caller compiles the chosen plan into sheets with
`--imposition` (chapter 6), which owns marks, tokens and print rotation.

Built for web2print (spec `php-web2print/docs/PACK-SPEC.md`, with the eight amendments agreed
2026-10-22). Source: `src/main/java/com/datahandler/epicenter/cli/pack/`.

## Invocation

```
java -jar java-epic-cli.jar --pack=REQUEST.json [--out=RESULT.json] [--usage=FILE] [--threads=N]
```

| flag | meaning |
|---|---|
| `--pack=FILE` | Required. The request (below). A positional path after `--pack` also works. |
| `--out=FILE` | Write the result there instead of stdout, atomically (temp file + move). The directory must exist and be writable; this is checked **before** the search, so a bad path fails at once (exit 1) rather than after the budget. |
| `--usage=FILE` | The usage sidecar (chapter 19); mode `pack`, plus `work.items` / `work.candidates` / `work.runs`. |
| `--threads=N` | Accepted (global flag) and has no effect on the result: the search is serial by design so the output is deterministic. |
| `--die-pdf=FILE` | *(2.26.0)* Roll requests only: also write the die layout PDF (see [Roll labels](#roll-labels-2260)). Checked before planning like `--out`; on a sheet request it is exit 1 and nothing is written. |
| `--nest-pdf=FILE` | *(2.30.0)* Nest requests only: also write the nested sheets (see [True-shape nesting](#true-shape-nesting-2300)). Checked before planning like `--out`; on any other request it is exit 1 and nothing is written. |

Anything else is exit 1.

## Exit codes and streams

| exit | meaning | stdout | stderr |
|---|---|---|---|
| 0 | A result, including `status` `partial` or `no_fit`. | the result JSON (empty with `--out`) | empty |
| 1 | Usage: unknown flag, no request path, unwritable `--out` / `--die-pdf` / `--nest-pdf`, `--die-pdf` without `"mode": "roll"`, `--nest-pdf` without `"mode": "nest"`. | empty | one line |
| 2 | Request invalid: unreadable file, not JSON, or a field out of range. | empty | one line naming the JSON path |
| 3 | Internal fault (a bug: please send the request). | empty | one line |

The stderr line starts `pack: ` and never contains `{` or `}` (consumers cut stdout at the first
brace). Examples: `pack: items[0].width must be > 0`, `pack: sheets[0].gripper.edge 'middle' is
not none, top, bottom, left or right`, `pack: strategies[0] must be one of [fewest_sheets,
least_waste, fewest_overs, best_margin]`.

## Request

Lengths are inches, money is in the caller's currency. Unknown keys are ignored.

```json
{
  "items": [
    { "id": "f81c…", "width": 3.75, "height": 2.25,
      "trim": { "width": 3.5, "height": 2.0 },
      "quantity": 1400, "inks": ["PANTONE 3005 C", "PANTONE Black 6 C"], "process": false,
      "rotate": "any", "price": 210.00, "group": null, "due": "2026-10-30", "priority": 0 }
  ],
  "sheets": [
    { "id": "12x18", "width": 12, "height": 18,
      "margins": { "top": 0.25, "right": 0.25, "bottom": 0.25, "left": 0.25 },
      "gripper": { "edge": "top", "size": 0.375 },
      "gap": { "x": 0.125, "y": 0.125 },
      "cost": { "sheet": 0.18, "impression": 0.02, "plate": 15.00, "run": 10.00,
                "cut": 0.50, "sheets_per_cut": 100 },
      "max_sheets": null }
  ],
  "constraints": {
    "max_inks_per_run": 6,
    "spoilage": { "sheets": 2, "percent": 0 },
    "max_overs_percent": null,
    "guillotine": true,
    "single_item_runs": false
  },
  "strategies": ["fewest_sheets", "least_waste", "fewest_overs", "best_margin"],
  "time_budget_ms": 20000,
  "seed": 1
}
```

**items[]** (required, non-empty)

| field | rule |
|---|---|
| `id` | Required, non-empty, unique. Echoed in the result. |
| `width`, `height` | Required, > 0. The placed **page** (bleed included). |
| `trim` | Optional `{width, height}`, centred on the page, not larger than it. Default = the page. Cuts sit on trim lines. |
| `quantity` | Required whole number ≥ 1. |
| `inks` | Spot ink names; duplicates collapse. May be empty. |
| `process` | `true` adds the four process plates to any run the item is in. |
| `rotate` | `any` (default: 0° or 90°) or `none`. |
| `price` | Optional, ≥ 0: what the customer pays for the whole quantity. |
| `group` | Optional. Every item with the same group prints in the same run. |
| `due`, `priority` | Optional `YYYY-MM-DD` / integer. Rank = due ascending (none last), then priority descending, then request order. Used to break ties in layouts and to choose what is dropped first when `max_sheets` cannot hold everything. |
| `max_overs` | *(2.11.0)* Optional whole number ≥ 0: copies over `quantity` this item may produce in its run, under every `fill`. It caps `fill: "max"` too. |

**sheets[]** (required, non-empty)

| field | rule |
|---|---|
| `id` | Required, non-empty, unique. |
| `width`, `height` | Required, > 0. |
| `margins` | No-art bands per edge, default 0. |
| `gripper` | `{edge: none|top|bottom|left|right, size}`. The band on that edge is `max(margin, size)`, not the sum (amendment 2). |
| `gap` | Space between neighbouring pages, x and y. Default 0. |
| `cost.sheet` | Per press sheet. |
| `cost.impression` | Per press sheet per plate (one pass per ink). |
| `cost.plate` | Per plate per run (plate, screen). |
| `cost.run` | Per run (makeready). |
| `cost.cut`, `cost.sheets_per_cut` | Per cut line per lift; a lift is up to `sheets_per_cut` sheets (default 100). |
| `max_sheets` | Optional stock limit across every run on this sheet, press sheets including spoilage. |

**constraints** (optional)

| field | rule |
|---|---|
| `max_inks_per_run` | A run's plates = the union of its items' inks, plus 4 if any item is process. Unlimited when absent. |
| `spoilage` | `{sheets, percent}`: press sheets = net + sheets + ceil(percent/100 × net) (amendment 6). |
| `max_overs_percent` | Per item per run: copies over the ordered quantity may not exceed this percent of it. |
| `guillotine` | Accepted; every layout is guillotine-cuttable regardless. |
| `single_item_runs` | `true` = never gang different items. A group with more than one item is then a request error (exit 2). |
| `same_inks` | *(2.11.0)* `true` = a run holds only items with one identical ink set (same `inks` as a set, same `process`). List backer plates in `inks` so they count. |
| `max_items_per_run` | *(2.11.0)* Whole number ≥ 1: distinct items in one run. |
| `fill` | *(2.11.0)* `balanced` (the same as absent): each run is offered at its fewest-sheets count and at its fewest-overs count, and the strategy picks. `min`: only the fewest-overs count is offered, so no strategy buys sheets with overs. `max`: after the count is chosen, spare room on the sheet is filled with more copies of the run's items (round robin, highest rank first) until nothing more fits or an item reaches `max_overs` / `max_overs_percent`. |

**press** *(2.11.0, optional)* — `{ "stations": N, "extra_pass_cost": X, "extra_pass_sheet_cost": Y }`.
A run with P plates takes ceil(P / N) passes through the press. Each pass after the first adds
`X + Y × press sheets` to the run's cost, as `cost.extra_passes`. That cost is in `cost.total`, so every strategy
weighs it. `stations` is required, ≥ 1; both costs default to 0. Plates are counted as for
`max_inks_per_run` (inks + 4 for process). Backers count only if they are in `inks`.

**placement** *(2.11.0, optional)* — `{ "order": "rows"|"columns"|"snake", "corner": "tl"|"tr"|"bl"|"br" }`,
defaults `rows` and `tl`. It changes only the **order** of `runs[].placements` and adds `seq`
(1..n). The layout, cuts and costs are unchanged. `rows` reads row by row from the corner (a row =
placements whose edge on the corner's side lies at the same distance). `columns` does the same by
columns. `snake` is `rows` with every other row reversed.

**cut_and_stack** *(2.11.0, optional)* — `{ "stack": N }`, or `{}` for one stack of the whole run.
- Cut-plan ranking adds sheet turns as a final tie-break. A turn is a region cut across the direction of the region it came from, meaning the pile is turned 90°.
- Each strategy's score adds turns as its last key.
- The cut-plan choice at a run's count prefers fewer turns when cut counts tie.
- Every placement is numbered for cut-and-stack.

**strategies** — any non-empty subset of the four, no repeats. Default all four, in that order.
**time_budget_ms** — whole number, default 20000, at most 600000. **seed** — any integer, default 1.

## Result

```json
{ "status": "ok", "elapsed_ms": 211, "evaluated": 30, "budget_hit": false,
  "recommended": "best_margin",
  "strategies": [
    { "strategy": "best_margin", "status": "ok",
      "totals": { "runs": 2, "press_sheets": 404, "plates": 6, "cost": 269.04, "price": 725.0,
                  "margin": 455.96, "waste_pct": 33.0, "overs": 200, "cuts": 41,
                  "stacked_cuts": 33 },
      "runs": [
        { "id": "r1", "sheet": "12x18", "press_sheets": 302, "inks": ["Red", "Blue"],
          "process": false, "plates": 2, "utilization_pct": 66.0,
          "placements": [ { "item": "cards-a", "x": 0.25, "y": 0.375, "width": 3.5, "height": 2.0,
                            "rotation": 0 } ],
          "cuts": [ { "x1": 0, "y1": 2.375, "x2": 12, "y2": 2.375, "kind": "strip", "order": 1 } ],
          "stacked_cuts": 21,
          "items": [ { "item": "cards-a", "up": 5, "produced": 1500, "ordered": 1400, "overs": 100 } ],
          "cost": { "stock": 54.36, "impressions": 12.08, "plates": 30.0, "run": 10.0,
                    "cuts": 42.0, "total": 148.44, "stack_strips": 42.0 },
          "price": 400.0, "margin": 251.56 } ],
      "items_summary": [ { "item": "cards-a", "ordered": 1400, "produced": 1500, "overs": 100,
                           "runs": ["r1"] } ],
      "unplaced": [ { "item": "huge", "reason": "larger than every sheet" } ] } ] }
```

| field | meaning |
|---|---|
| `status` | The **recommended** strategy's status: `ok` (everything placed), `partial`, `no_fit` (nothing placed). |
| `evaluated` | Candidate runs priced across the whole search (one per item set × sheet). |
| `budget_hit` | `true` when the time budget cut a search short (see *Time and determinism*). |
| `recommended` | `best_margin` when every item has a price, else `fewest_sheets`, else the first strategy requested — only ever one that was requested. |
| `runs[].placements` | One per copy. `x`/`y`/`width`/`height` are the page footprint **on the sheet**, top-left origin, already swapped when `rotation` is 90. |
| `runs[].cuts` | Guillotine cuts in cutting order (below). |
| `runs[].stacked_cuts`, `runs[].cost.stack_strips`, `totals.stacked_cuts` | *(2.5.0)* The same plan with identical regions piled and cut together: distinct lines, and their price. Informational: they are not in `cost.total` (see *Stacked cuts*). |
| `runs[].items[].up` | Copies on each sheet; `produced` = up × net sheets (spoilage excluded). |
| `runs[].utilization_pct` | Ordered trim area on one sheet ÷ sheet area. |
| `runs[].price` | Sum of its items' prices; `null` when any item in the run has none. `margin` likewise. |
| `totals.waste_pct` | 1 − Σ ordered trim area ÷ Σ printed sheet area (spoilage sheets included). |
| `totals.cost` / `price` / `margin` | Sums over runs; `price`/`margin` `null` when any run's is. |
| `unplaced[]` | Every item not in a run, with the reason. |

**2.11.0 fields.** Each appears only when its request key is set, so a request without the new keys returns exactly the 2.10.0 result.

| field | when | meaning |
|---|---|---|
| `runs[].passes`, `runs[].cost.extra_passes`, `totals.extra_passes` | `press` | Passes for this run. The cost of the passes beyond the first. Σ over runs of (passes − 1). |
| `runs[].placements[].seq` | `placement` | Reading order, 1..n. |
| `runs[].cut_and_stack` | `cut_and_stack` | `{stack_sheets, stacks, turns}`. `stack_sheets` = min(N, net sheets), and `stacks` = ceil(net / stack_sheets). |
| `runs[].placements[].stack_position`, `first_number`, `last_number` | `cut_and_stack` | The k-th placement of an item in the run (in `placements` order) carries numbers (k−1)·S+1 … k·S of the first stack, where S = `stack_sheets`. Stack b (0-based) adds b × up × S. Numbers are per item. |
| `totals.turns` | `cut_and_stack` | Σ over runs of turns. |
| `totals.filled` | `fill: "max"` | Copies added beyond each count's own ups. They are also in `overs`. |
| `constraints_applied` | any 2.11.0 key | One line per rule set, so the studio can explain the plan, e.g. `"press.stations 2: 1 of 2 runs need an extra pass"`, `"same_inks: 4 runs over 4 ink sets"`. Text for people, not for parsing. |

Money is rounded to cents per component, and a run's `total` is the cents sum of its components.

### Unplaced reasons

| reason | when |
|---|---|
| `larger than every sheet` | The page fits no sheet's usable area in an allowed orientation. |
| `group 'G' contains X, larger than every sheet` | One member of a group cannot fit, so the group cannot run. |
| `inks exceed max_inks_per_run (N > M)` | The item alone needs more plates than allowed. `group 'G' inks exceed …` for a group. |
| `no sheet count stays within max_overs_percent` | Every count that fits overshoots the overs limit (`group 'G' …` for a group). `… and max_overs`, or `no sheet count stays within max_overs` alone, when an item's `max_overs` is set (2.11.0). |
| `group 'G' mixes ink sets under same_inks` | *(2.11.0)* A group whose members' ink sets differ while `same_inks` is on. |
| `group 'G' has more items than max_items_per_run (N > M)` | *(2.11.0)* A group larger than the cap. |
| `group 'G' does not fit on any sheet together` | A group whose members fit alone but not on one sheet together. |
| `sheet stock exhausted (max_sheets)` | Dropped (lowest rank first) because the stock could not hold it. Dropped items are offered back, highest rank first, whenever the remaining plan leaves room. |

## How a run is laid out

A run's layout is a three-stage guillotine pattern on the usable area: horizontal strips across
the sheet; inside a strip, segments side by side, each one item in one orientation; inside a
segment, copies stacked. The same pattern is tried transposed (vertical strips). Orientation
policies (as given, rotated, landscape, portrait, best grid alone) × fill orders (tallest,
largest, rank first) × the two directions give the candidate patterns.

For each run (item set × sheet) the sheet count is searched: the smallest net count N whose ups
fit, then up to 48 candidate counts from the quantities, keeping the fewest-sheets option and the
fewest-overs option that respect `max_overs_percent`. At the chosen count **every** fitting
pattern is built and the one with the fewest cuts kept (cuts are priced).

## Cuts

Cuts sit on **trim** lines (amendment 1). A line is shared only where two trims touch exactly
(gap 0, no bleed); with bleed or a gap each trim edge is its own cut. The plan is a true
guillotine sequence: the planner splits the sheet on every line that crosses no trim box, in
whichever direction (all horizontal or all vertical) gives the fewest cuts overall, then plans
each part the same way. Each cut spans its region edge to edge, so the list can be followed in
`order` on a guillotine.

`kind` is `strip` when pieces lie on both sides of the cut within its region, `trim` when it only
takes off waste. Cut cost = `cost.cut` × lines × ceil(press sheets / `sheets_per_cut`).

The line count assumes every region is cut separately. Without restacking, separating P pieces
needs at least P − 1 strip cuts, so a large grid of small items prices close to one cut per
piece (1,088 stickers on 13×19, gap 0: 1,087 strip + 27 trim).

### Stacked cuts (2.5.0)

Shops pile identical strips and cut them together. `runs[].stacked_cuts` and
`runs[].cost.stack_strips` price the **same** plan that way. Every region the plan cuts belongs to
a pattern: its size plus its trim boxes relative to its corner. Identical patterns are cut by
identical lines, so each pattern's lines are counted once. The pile is every copy of the
pattern on every press sheet, split into lifts:

    stacked_cuts      = Σ over patterns of lines
    cost.stack_strips = cost.cut × Σ over patterns of lines × ceil(copies × press sheets / sheets_per_cut)

Both are additive and informational. `cost.cuts`, `cost.total`, margins and every strategy's
choices still use the unstacked price, so results are unchanged apart from the new fields.
`stack_strips` is never above `cost.cuts`. Under a per-line-**per-lift** price, stacking saves
money only where lifts are part-full: a pile of 36 strips is 36 times as tall, so it needs that
many more lifts. The large drop is in **lines** (strokes to set up): 50,000 stickers at 618 up
fall from 1,282 lines to 152, but their cost only from 641.00 to 560.00 at 83 press sheets. A
pattern that differs at all (a short last column, another item) is its own pile, and rotated
twins are not merged.

## Searching the plan

Items join units (a group is one unit). Each strategy scores runs left to right:

| strategy | score |
|---|---|
| `fewest_sheets` | press sheets, cost, overs |
| `least_waste` | printed sheet area, cost, press sheets |
| `fewest_overs` | overs, press sheets, cost |
| `best_margin` | cost, press sheets, overs (prices are fixed, so lowest cost = best margin) |

The baseline is one run per unit. Eight rounds then start from singletons, ink clusters,
everything in one run, and seeded random partitions, each improved by first-improvement moves
(merge two runs, move a unit, split a unit out; at most 4,000 tries per round). A plan that
overdraws a sheet's `max_sheets` is first repaired by moving runs to other sheet options; if it
still overdraws, the lowest-ranked unit is dropped and the plan re-searched.

In this version each item prints in **exactly one run**; `items_summary[].runs` is a list so a
later version can let an item span runs without changing the shape.

## Time and determinism

`time_budget_ms` is split equally across the strategies still to run, so the first cannot
starve the rest. The baseline, the per-unit fit checks and the `max_sheets` pass always
complete (on 60 items about 0.9 s), so a tiny budget still returns a valid plan. A round the
budget interrupts keeps the best plan it had reached, and `budget_hit` is `true`.

Without a budget hit the result is byte-identical for the same request and seed, at any
`--threads` (`elapsed_ms` aside). With a budget hit it depends on machine speed.

## Roll labels (2.26.0)

A request with `"mode": "roll"` plans labels die-cut from a web on a rotary press instead of
press sheets. Any other request, including one with no `mode` or another `mode` value, takes the
sheet planner above exactly as before (unknown keys were always ignored, so the only request
whose answer changes is one that already said `"mode": "roll"`).

A roll run is **one die on one cylinder**: every lane cuts the same label size, lanes may carry
different items, and every lane prints on every revolution (a *frame*). Lengths are inches; `x`
runs across the web from its left edge, `y` runs around the cylinder from the seam.

### Request

| key | meaning |
|---|---|
| `mode` | `"roll"`. |
| `items[]` | As for sheets: `id`, `width`/`height` (art with bleed), optional `trim` (the die-cut size; default the page), `quantity`, `inks`, `process`, `rotate` (`any` default / `none`), `price`, `group`, `max_overs`. Roll only: `corner_radius` (die corner, at most half the short side). A group must share one trim and one `rotate` (exit 2 otherwise): it always runs together and a run is one die. `due` / `priority` are not read in roll mode. |
| `web.width` | Required. Material width. |
| `web.margins {left, right}` | Unprinted edge bands (default 0). Printable width = width − left − right. |
| `web.gap_across` | Space between lanes (default 0). |
| `web.min_gap_around` | The least space between labels around the cylinder (default 0). The gap actually used is whatever the repeat leaves. |
| `web.max_lanes` | Cap on lanes (default: as many as fit). |
| `web.align` | `center` (default) or `left`: where the lanes sit in the printable width. |
| `web.stagger` | `true`: odd lanes (1, 3, …) are shifted half a label pitch around. |
| `web.cost {msi, linear_foot, run, plate, die}` | Material per 1,000 square inches of web and/or per linear foot, a set-up per run, a plate per ink per run, a new die. All default 0. |
| `cylinders` | Required. `teeth: [..]` with `pitch` (inches per tooth: repeat = teeth × pitch), and/or `repeats: [..]` in inches. Duplicates collapse; at least one is needed. The caller supplies the pitch: none is assumed. |
| `dies[]` | Optional inventory of existing dies: `id`, `width` × `height` (label as it sits on the web: width across), `across`, `around`, and `teeth` or `repeat`. A die costs nothing and is only offered when its repeat is one of the listed cylinders, it fits the printable width with `gap_across`, it has a lane for each item in the run and it does not exceed `max_lanes`. |
| `distortion` | Optional. `k` (the caller's own plate constant, inches) **or** `around_pct`; `across_pct`. Reported per run (below); never changes the layout. |
| `constraints` | `max_inks_per_run`, `max_items_per_run`, `single_item_runs`, `same_inks` as for sheets; `spoilage {length (inches per run), percent}`. |

`sheets` with `mode: "roll"` is exit 2 (`pack: sheets is not used with mode roll ...`); so is a
missing `web` or `cylinders`, `teeth` without `pitch`, `k` together with `around_pct`, or a die
whose `around × height` exceeds its repeat.

### How a run is laid out (plain arithmetic)

```
across  = floor((printable width + gap_across) / (label width + gap_across)), capped by max_lanes
around  = floor(repeat / (label height + min_gap_around))
pitch   = repeat / around          gap_around = pitch - label height
row r   at y = gap_around/2 + r x pitch   (+ pitch/2 on odd lanes when staggered)
lane j  at x = left margin + offset + j x (label width + gap_across)
              (offset centres the lane block in the printable width unless align is left)
frames  = max over the run's items of ceil(quantity / (its lanes x around))
produced = lanes x around x frames per item; overs = produced - quantity
net length = frames x repeat; spoilage = spoilage.length + net x percent/100; length = net + spoilage
cost    = length x web width / 1000 x msi + length / 12 x linear_foot
          + plates x plate + die (0 for an inventory die) + run
plates  = union of the run's spot inks, +4 when any item is process (same rule as sheets)
```

The seam falls in the middle of a gap. Unstaggered labels never cross it; with `stagger` the last
row of an odd lane does, and its position says `wraps_seam: true`.

**Lanes.** Each item gets one lane, then the spare lanes go one at a time to whichever item
needs the most frames (ties to the earlier item). That minimises the run's frames.

**Runs.** Items are grouped by label size (short × long side). Within a size, units (a group or a
single item) are taken largest quantity first and put into the first run with a free lane per
item and room under the ink and item limits, else a new run (`single_item_runs`: always a new
run). Labels with `rotate: none` keep their orientation; `rotate: any` labels join the locked
orientation with the larger quantity or, when nothing is locked, take whichever orientation
places more items at the lower total (the first, short side across, on a tie).

**Gear and die.** For each run every listed cylinder is scored with the most lanes that fit,
plus every usable inventory die with its own lanes and around. The cheapest total wins, then the
shorter length; an inventory die is scored first so an exact tie keeps the die on the shelf. A
new layout that exactly matches an inventory die *is* that die (cost 0). Up to five runners-up
are listed in `alternatives[]`.

**Distortion.** `around_pct = 100 × k / repeat` when `k` is given (else the caller's
`around_pct`), `across_pct` as given, and `art_scale_* = 1 − pct/100`: the factor to scale the
art by before plating. The planner states the arithmetic only; `k` belongs to the shop's plates
and press, and no value is assumed.

### Result

```
{"mode":"roll", "status":"ok|partial|no_fit", "elapsed_ms":.., "evaluated":N,
 "web":{"width","print_width","align"},
 "totals":{"runs","length","length_feet","area_msi","produced","overs","cost"},
 "runs":[{"run":1, "label":{"width","height"}, "cylinder":{"repeat","teeth","pitch"},
          "die":{"id":null|"D1","new":true|false}, "across","around","gap_across","gap_around",
          "stagger", "inks":[..], "plates", "frames", "net_length", "spoilage_length", "length",
          "length_feet", "area_msi",
          "distortion":{"around_pct","across_pct","art_scale_around","art_scale_across"},
          "items":[{"id","rotated","lanes":[0,1],"quantity","produced","overs"}],
          "cost":{"material","plates","die","run","total"},
          "positions":[{"lane","row","x","y","w","h","item","wraps_seam"}],
          "alternatives":[{"repeat","teeth","die_id","across","around","frames","length","total"}],
          "warnings":[..]}],
 "unplaced":[{"id","reason"}],
 "die_pdf":null|"/abs/path.pdf"}
```

`positions[]` is one frame: every label on the die, lane by lane, row by row. `w` × `h` is the
label as it sits on the web; `rotated` says the item was turned to get there. Values are rounded
to 4 decimals (art scales to 6). `status` is `ok` when every item is placed, `partial` when some
are in `unplaced[]`, `no_fit` when none are. Reasons: `label is wider than the printable web
width`, `label is longer than every cylinder repeat`, `group needs N lanes; only M fit across`,
`needs more plates than constraints.max_inks_per_run`, `group has more items than
constraints.max_items_per_run`.

Per-run `warnings[]`: an item printing more overs than its `max_overs` (every lane prints every
frame, so overs can be forced), and bleed (page − trim, both sides together) wider than
`gap_across` (with more than one lane) or `gap_around`, which means neighbouring art overlaps.

### Die layout PDF

`--die-pdf=FILE` writes one page per run, `web.width` × repeat, each label's die line stroked
0.25 pt in `spot('CutContour',0,100,0,0)` with overprint (rounded with `corner_radius`). The
file is written atomically before the JSON is printed. `--export-cut` lifts it directly. A
seam-crossing staggered label is drawn on both sides of the seam; those paths run past the page
edge and the page box clips them. With no runs, no file is written and `die_pdf` is `null`.

Measured on `tools/pack/fixtures/roll.json` (2 items, 4 cylinders, 1 die): 1 run on the
inventory die, `--export-cut` lifts 20 closed paths, bbox as computed. Every figure in the
manual example below was checked by hand:

```
web 13 in, margins 0.25 + 0.25, gap_across 0.125, min_gap_around 0.125, label 3 x 2
across = floor(12.625 / 3.125) = 4
72 teeth x 0.125 = 9 in: around = floor(9 / 2.125) = 4, gap_around 0.25
A 10,000 + B 5,000: lanes A 3, B 1 -> frames max(834, 1250) = 1250, length 11,250 in
cost 11,250 x 13 / 1000 x 0.50 + 2 plates x 25 + new die 300 + run 50 = 473.125
inventory die D1 (4 x 5 on 96 teeth = 12 in): frames 1000, length 12,000, cost 178 -> chosen
```

## True-shape nesting (2.30.0)

`"mode": "nest"` nests print-and-cut parts (stickers, decals, heat-transfer shapes) by their real
outline instead of their bounding box, on cut sheets or a roll. Only a request whose `mode` is
exactly the string `"nest"` takes this path; every other request is answered exactly as before.
Unlike the rest of `--pack`, nesting **does** open artwork: a `pdf` part's outline is traced from
its ink.

```
java -jar java-epic-cli.jar --pack=NEST.json [--out=RESULT.json] [--nest-pdf=SHEETS.pdf]
```

### Request

All lengths in inches; contours are y-down like the rest of `--pack`.

| field | default | meaning |
|---|---|---|
| `sheet.width`, `sheet.height` | required | The sheet. With `roll: true`, `width` is the web and `height` the longest run allowed. |
| `sheet.roll` | `false` | One roll: everything goes on one run and the result reports the length used. |
| `sheet.max_sheets` | unlimited | Cut sheets only (exit 2 with a roll). Copies that do not fit are `unplaced`. |
| `sheet.margin` | 0 | A number, or `{top, right, bottom, left}`. No part's cut line enters it. |
| `spacing` | 0 | Minimum distance between the cut lines of any two parts. |
| `rotation_step` | 90 | Allowed rotations in degrees; must divide 360 (1–180); `0` = never rotate. |
| `resolution` | 0.02 | Search grid cell, 0.005–0.25. Finer packs a little tighter and runs slower. |
| `passes` | 8 | Orderings tried (area, longest side, height, width, then seeded variations of area); at most 64. |
| `seed` | 1 | Seeds the variations. Same request + seed = same result. |
| `time_budget_ms` | 60000 | At most 600000. Pass 1 always finishes; later passes stop at the budget (`budget_hit: true`). |
| `trace_dpi` | 300 | Raster resolution of the ink trace for `pdf` parts, 36–1200. |
| `items[].id` | required | Unique. |
| `items[].quantity` | required | 1–100,000. |
| `items[].rotate` | `true` | `false` keeps the part upright. |
| `items[].pdf` + `page` (1) + `cut_offset` (0) | one source of three | The part is the page's art. Its cut line is the `--cut-contour` line: the outer ink footprint grown by `cut_offset` (−2 to 2; negative cuts into the art, which still keeps its own clearance). A relative path is relative to the request file. |
| `items[].cut` *(2.31.0)* | `"trace"` | `pdf` parts only. `"art"`: the art already carries its cut line, so cut on that. The cut line is the union of the closed paths the page strokes in a `cut_spots` ink (forms included, outer boundary only). The part is nested by that line plus any ink outside it (bleed still keeps clear of neighbours), and `--nest-pdf` draws no second line. `cut_offset` is refused with it. Open paths are ignored with a warning. No closed path at all is exit 2: `items[0].pdf page 1 strokes no closed path in [CutContour] for cut art`. |
| `items[].cut_spots` *(2.31.0)* | `["CutContour"]` | With `cut: "art"` only. The ink names that are cut lines, compared as `--export-cut` compares them (case, spaces, `_` and `-` ignored). |
| `items[].contour` | | `[[x, y], …]`, at least 3 points; the polygon is the cut line. |
| `items[].width` + `height` | | A rectangle. |

### How it nests

Each allowed rotation of a part is grown by `spacing / 2` plus one grid cell's half diagonal and
the curve flattening tolerance, then sampled onto the grid. Because the grid only ever *adds*
cells, two parts whose cells do not meet have cut lines at least `spacing` apart: the spacing is
a guarantee, measured, not an approximation (see Tools). The cost is a fraction of a cell of
extra air between parts: about 1 pt at the default resolution.

Placement is bottom-left gravity. Each copy takes, over its allowed rotations, the position
whose top edge ends highest on the sheet, then leftmost; sheets fill first-fit, so small parts
fall into gaps on earlier sheets. Each pass places every copy in one order; the plan placing the
most copies, on the fewest sheets, with the shortest last sheet (the roll length) wins.

### Result

```
{"mode": "nest", "status": "ok" | "partial" | "no_fit", "elapsed_ms", "evaluated",
 "passes_run", "best_pass", "best_order", "budget_hit",
 "settings": {spacing, resolution, rotation_step, passes, seed},
 "sheet": {width, height, roll, print_width, print_height},
 "totals": {sheets, placed, unplaced, part_area, utilization_pct, length, length_feet},
 "items": [{id, source, quantity, placed, area, width, height, rotations, traced, cut?}],
 "sheets": [{sheet, width, length, used, utilization_pct,
             placements: [{item, copy, x, y, width, height, rotation, matrix}]}],
 "unplaced": [{item, copies, reason}], "warnings": [], "nest_pdf"}
```

- `x`, `y`, `width`, `height`: the part's cut-line bounding box on the sheet, top-left origin.
- `rotation`: degrees counter-clockwise as seen on the sheet.
- `matrix`: `[a b c d e f]` taking the item's own page space (PDF points, y up; for a contour
  part, the contour with y flipped about its largest y, ×72) to the sheet page (PDF points, y
  up, origin bottom-left). Use it as a `cm` to draw the art.
- `length`: the sheet's page length. For a roll it is margin + used + margin; `used` is the part
  extent alone. `utilization_pct` is part area over printable area (a roll: over the used length).
- `area`, `part_area`: square inches inside the cut line (for `cut: "art"`, plus any ink outside it).
- `items[].cut` *(2.31.0)*: present, as `"art"`, only on cut-art items, so a 2.30.0 request's result is unchanged.
- A traced `pdf` part whose art already strokes `CutContour` is cut twice: once on its own line, which arrives with the art, and once on the traced line. Use `"cut": "art"` to cut only one. There is no warning, so that 2.30.0 results stay byte-identical.
- `unplaced[].reason`: `larger than the printable area in every allowed rotation`,
  `sheet.max_sheets is used up`, or `the roll length is used up`.

### Nested sheets PDF

`--nest-pdf=FILE` writes one page per sheet (a roll: one page, web width × `length`) with each
`pdf` part's art placed by its matrix, and every part's cut line (except `cut: "art"` parts, whose own line arrives with the art) stroked 0.25 pt in
`spot('CutContour',0,100,0,0)` with overprint, so it goes straight to a print-and-cut RIP or to
`--export-cut`. Written atomically before the JSON. With nothing placed, no file is written
and `nest_pdf` is `null`. A source page with `/Rotate` is nested as drawn in page space (a
warning says so).

## Measured (dev VM)

| request | wall | result |
|---|---|---|
| `tools/pack/fixtures/basic.json`, 4 items, 2 sheets | 0.7 s | 4 strategies ok, 30 candidates |
| `tools/pack/fixtures/sixty.json`, 60 items, 3 sheets, budget 60 s | 13–16 s | 31,368 candidates, no budget hit; heap under 128 MB (`-Xmx128m` gives the identical result) |
| same, budget 2 s | 2.0 s | budget hit; best_margin cost 7,231 vs 6,981 unhit, 8,042 baseline |
| 2 sticker items, 290,000 copies | 0.8 s | 1,088 up on 13×19, 0 problems |
| `tools/nest/fixtures/shapes.json` (nest), 38 parts, 24 in roll | 2.0 s | 19.44 in used, 64.3 % utilization, min cut gap 10.8 pt for 9 pt spacing; `--export-cut` lifts 38 closed paths |
| nest, 400 parts (2 traced PDFs + contours + rects), 54 in roll | 3.4 s, 265 MB RSS | 108.3 in used, 66.5 %, min gap 10.0 pt for 9 pt |
| nest 2.31.0, `tools/nest/fixtures/cut_art.pdf` ×6 `cut: "art"` + ×3 traced + `cut_none.pdf` ×3 | 0.9 s | 12 placed; nest PDF strokes 6 new lines (traced + plain only), and the 6 art lines are found where placed (≤ 0.02 pt) |

## Tools

- `tools/pack/check.py REQUEST RESULT` — independent validator: usable band, overlap and gap,
  ups, press/produced math, every cut on a trim line and crossing no trim box, every trim edge
  cut, cost parts and totals, placed/unplaced partition. Positive control: three corrupted results,
  all flagged.
- `tools/pack/draw.py REQUEST RESULT OUTDIR [PPI]` — a PNG per strategy and run (red = trim
  cuts, blue = strip cuts).
- `tools/pack/gen_sixty.py` — regenerates the 60-item fixture.
- `tools/pack/fixtures/` — `basic.json`, `edges.json` (oversize, ink overflow, groups, no-rotate,
  due/priority, max_overs), `sixty.json`.
- `tools/pack/check_roll.py REQUEST RESULT` *(2.26.0)* — independent roll validator: every label
  inside the printable web and the repeat, no overlap (across the seam too), gaps, lanes 0..n−1
  once each, frames = the minimum the lanes need, produced/overs, plates, distortion, cost parts
  and totals, die match, placed/unplaced partition. Positive control: five corrupted results
  (overlap, frames, cost, missing item, off-web), all flagged.
- `tools/pack/fixtures/roll.json` (the worked example) and `roll_edges.json` (group, locked
  orientation, stagger, process plates, inventory die, spoilage, max_overs warning, too wide, too
  long, too many inks).
- `tools/nest/check_nest.py JAR REQUEST RESULT NEST.pdf` *(2.30.0)* — independent nest
  validator (shapely, exact polygons, no grid): rebuilds each part's cut line from the request
  (contour/rect) or from `--cut-contour` on the part's PDF, maps it through the result's
  matrix, and checks it equals the PDF's CutContour line (≤ 0.02 pt), lies inside the printable
  area, and stays ≥ `spacing` from every other part; plus page sizes and placed + unplaced =
  quantity. Positive control: `--corrupt=overlap|drop|spacing`, all flagged. *(2.31.0)* For
  `cut: "art"` items it reads the art's own CutContour line out of its PDF (form XObjects walked),
  requires that the nest draw no line for them, and finds each art line through the matrix in the
  nest PDF's form-walked lines; `--corrupt=cutline` (expect a drawn line) is flagged.
- `tools/nest/fixtures/shapes.json` — traced PDF, triangle, L, star, fixed-orientation cards.
- `tools/nest/make_cut_art.py OUTDIR` *(2.31.0)* — hand-written `cut_art.pdf` (closed CutContour
  inside a form, bleed tab across the line), `cut_open.pdf` (line with a real gap), `cut_none.pdf`;
  deterministic.
