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
- Forklift loads from the rear. The nose fills first; you can't place a unit behind one that's already in. Load order is the reverse of unload order, so stop sequence matters too (left out here).
- Two across. 48" + 48" = 96" in a 100" interior. With units oriented 40" along the length, 630" gives 15 rows. 30 floor positions.
- Stacking. A unit can go on top of another if the stack stays under the 110" roof and the top unit is no heavier than the one below — double wall doesn't ride on single wall.
- Weight. 44,000 lb total, and in practice distributed so neither axle group is over.
The load
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:
- Sort every unit on the dock by height, tallest first.
- 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.
- You now have more stacks than the 30 floor positions. Rank them by MSF and take the top 30.
- 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.
- 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;
}