2026-10-15 · operations research · corrugated · 3d

Loading the most MSF onto a 53-foot trailer

Truck cubing the way a corrugated plant actually does it: units of bundled board, placed by forklift through the rear door, stacked where the footprint and weight allow. The objective isn't boxes or pallets. It's thousand square feet of board on the truck.

The knapsack post showed a side view with pallets. This is the real thing in three dimensions, with the constraints a shipping supervisor would recognize. Drag to rotate.

What a unit is

Board comes off the corrugator as sheets, gets bundled, and bundles get stacked into a unit on a 48×40 footprint — three 16-inch bundles across. A unit is 36, 48, or 60 inches tall depending on the order. The amount of board in it depends on the flute: C-flute is about 0.16" thick, so a 48" stack is 300 sheets; BC double-wall is about 0.28", so the same height is only 170 sheets. Each sheet is 48×40 = 13.3 sq ft. So:

MSF per unit = (48 × 40 / 144) × (height / caliper) / 1000
48" of C-flute   → 13.33 × 300 / 1000 = 4.0 MSF
48" of BC        → 13.33 × 171 / 1000 = 2.3 MSF

Weight follows from the board grade — roughly 120 lb per MSF for single wall, 190 for double wall. That matters because a 53' van is limited to about 44,000 lb of payload, and double wall hits the weight limit before the roof.

The constraints

The load

drag to rotate · scroll to zoom
Units loaded—
MSF on truck—
Weight—
Cube used—
Left on dock—
B-flute single wallC-flute single wallBC double wallheight = unit height

The two strategies

Dock order is what happens without a plan: units come to the door in the order they were staged, each goes to the next open floor spot nose-first, and a unit gets stacked only if it happens to fit on the one just placed. It's fast and it's what a rushed shift does.

MSF-max plans the stacks before anything moves:

  1. Sort every unit on the dock by height, tallest first.
  2. For each unit not yet assigned, find the tallest remaining unit that can legally go on top of it (fits under the roof, not heavier). Pair them into a stack. This is first-fit-decreasing bin packing where the bins are 110" columns — the same heuristic from the trailer side view, now with a weight rule.
  3. You now have more stacks than the 30 floor positions. Rank them by MSF and take the top 30.
  4. Check total weight. If over 44,000 lb, swap out the stack with the worst MSF-per-pound for the best unplaced one that fits, until it's under.
  5. Place heavy stacks toward the front-middle of the trailer and light ones at the tail, for axle balance; load them nose-first in that order.

Still a heuristic — nothing above is provably optimal — but every step has a reason, and it beats dock order by a margin you can see. The exact version is a MILP: binary variables for unit-to-position assignment, stacking pairs, with roof, footprint, weight, and sequence constraints, solved with OR-Tools. On a real order book of 60–80 units it solves in seconds and the heuristic becomes the warm start. That's what truck-cubing-bin-packing is working toward.

Where MSF changes the answer

If you maximize units, you'd fill the floor with short 36" stacks and double them up — lots of units, lots of air above them. If you maximize weight, you'd load double wall until the axles complain, with half the trailer empty. Maximizing MSF forces the actual trade: tall single-wall stacks that use the roof, enough double wall to use the weight, and a plan for which goes where. It's a two-constraint knapsack (cube and weight) with a packing problem inside it, and the objective is the one the plant gets paid on.

The Java

record Unit(int id, int height, String flute, double msf, double lb) {}
record Stack(List<Unit> units) {
    int height() { return units.stream().mapToInt(Unit::height).sum(); }
    double msf()  { return units.stream().mapToDouble(Unit::msf).sum(); }
    double lb()   { return units.stream().mapToDouble(Unit::lb).sum(); }
}

static final int ROOF = 110, SPOTS = 30; static final double MAX_LB = 44_000;

static List<Stack> plan(List<Unit> dock) {
    List<Unit> byHeight = new ArrayList<>(dock);
    byHeight.sort(Comparator.comparingInt(Unit::height).reversed());
    Set<Unit> used = new HashSet<>();
    List<Stack> stacks = new ArrayList<>();

    for (Unit base : byHeight) {                       // 1–2: pair stacks, FFD by height
        if (used.contains(base)) continue;
        used.add(base);
        List<Unit> col = new ArrayList<>(List.of(base));
        for (Unit top : byHeight) {
            if (used.contains(top)) continue;
            if (base.height() + top.height() <= ROOF && top.lb() <= base.lb()) {
                col.add(top); used.add(top); break;    // tallest legal partner
            }
        }
        stacks.add(new Stack(col));
    }

    stacks.sort(Comparator.comparingDouble(Stack::msf).reversed());   // 3: best 30 by MSF
    List<Stack> load = new ArrayList<>(stacks.subList(0, Math.min(SPOTS, stacks.size())));
    List<Stack> rest = new ArrayList<>(stacks.subList(load.size(), stacks.size()));

    while (load.stream().mapToDouble(Stack::lb).sum() > MAX_LB) {     // 4: weight repair
        Stack worst = Collections.min(load, Comparator.comparingDouble(s -> s.msf() / s.lb()));
        load.remove(worst); rest.add(worst);
        rest.sort(Comparator.comparingDouble(Stack::msf).reversed());
        double room = MAX_LB - load.stream().mapToDouble(Stack::lb).sum();
        rest.stream().filter(s -> s.lb() <= room).findFirst().ifPresent(s -> { load.add(s); rest.remove(s); });
    }
    load.sort(Comparator.comparingDouble(Stack::lb).reversed());       // 5: heavy toward the nose
    return load;
}

Dimensions and board properties are representative, not a specific plant's. Instance, heuristics, and the 3D scene are mine; three.js for rendering.