use super::common::{
BinPlacements, build_solution, check_bin_count_cap, surface_area_u64, volume_u64,
};
use super::model::{
Bin3D, ItemInstance3D, Placement3D, Rotation3D, SolverMetrics3D, ThreeDOptions, ThreeDProblem,
ThreeDSolution,
};
use crate::Result;
use crate::two_d::{self, ItemInstance2D, TwoDAlgorithm, TwoDOptions};
#[derive(Debug, Clone)]
struct Stack {
footprint_x: u32,
footprint_z: u32,
accumulated_height: u32,
items: Vec<StackEntry>,
}
#[derive(Debug, Clone)]
struct StackEntry {
item: ItemInstance3D,
rotation: Rotation3D,
y_extent: u32,
x_extent: u32,
z_extent: u32,
}
#[derive(Debug, Clone)]
struct FlatItem {
item: ItemInstance3D,
flat_rotation: Rotation3D,
flat_x_extent: u32,
flat_y_extent: u32,
flat_z_extent: u32,
flat_area: u64,
}
pub(super) fn solve_column_building(
problem: &ThreeDProblem,
_options: &ThreeDOptions,
) -> Result<ThreeDSolution> {
let mut flat_items: Vec<FlatItem> = problem
.expanded_items()
.into_iter()
.map(|item| pick_flat_orientation(item, &problem.bins))
.collect();
flat_items.sort_by(|left, right| right.flat_area.cmp(&left.flat_area));
let max_bin_height = problem.bins.iter().map(|bin| bin.height).max().unwrap_or(0);
let mut stacks: Vec<Stack> = Vec::new();
let mut leftover_items: Vec<ItemInstance3D> = Vec::new();
for flat in flat_items {
if let Some(target) = pick_stack_for(&stacks, &flat, max_bin_height) {
push_onto_stack(&mut stacks[target.index], &flat, target.rotation);
} else if flat.flat_y_extent <= max_bin_height {
stacks.push(Stack {
footprint_x: flat.flat_x_extent,
footprint_z: flat.flat_z_extent,
accumulated_height: flat.flat_y_extent,
items: vec![StackEntry {
item: flat.item,
rotation: flat.flat_rotation,
y_extent: flat.flat_y_extent,
x_extent: flat.flat_x_extent,
z_extent: flat.flat_z_extent,
}],
});
} else {
leftover_items.push(flat.item);
}
}
let mut bin_placements: Vec<BinPlacements> = Vec::new();
let mut bin_quantity_used: Vec<usize> = vec![0; problem.bins.len()];
let mut pending: Vec<Stack> = stacks;
while !pending.is_empty() {
check_bin_count_cap(bin_placements.len())?;
let bin_index = match select_next_bin(problem, &bin_quantity_used, &pending) {
Some(index) => index,
None => break,
};
let bin = &problem.bins[bin_index];
bin_quantity_used[bin_index] = bin_quantity_used[bin_index].saturating_add(1);
let mut stage_indices: Vec<usize> = Vec::new();
let mut carry_indices: Vec<usize> = Vec::new();
for (index, stack) in pending.iter().enumerate() {
if stack_fits_bin(stack, bin) {
stage_indices.push(index);
} else {
carry_indices.push(index);
}
}
if stage_indices.is_empty() {
debug_assert!(false, "select_next_bin returned a bin that fits no remaining stack");
break;
}
let items_2d: Vec<ItemInstance2D> = stage_indices
.iter()
.map(|&index| {
let stack = &pending[index];
ItemInstance2D {
name: format!("__stack_{index}__"),
width: stack.footprint_x,
height: stack.footprint_z,
can_rotate: false,
}
})
.collect();
let (placements_2d, unplaced_2d) = two_d::place_into_sheet(
&items_2d,
bin.width,
bin.depth,
TwoDAlgorithm::Auto,
&TwoDOptions::default(),
)?;
let mut layout: Vec<Placement3D> = Vec::new();
for p2d in placements_2d {
let Some(stack_index) = parse_stack_index(&p2d.name) else {
debug_assert!(false, "place_into_sheet returned unexpected name {}", p2d.name);
continue;
};
let stack = &pending[stack_index];
let mut cursor_y: u32 = 0;
for entry in &stack.items {
layout.push(Placement3D {
name: entry.item.name.clone(),
x: p2d.x,
y: cursor_y,
z: p2d.y,
width: entry.x_extent,
height: entry.y_extent,
depth: entry.z_extent,
rotation: entry.rotation,
});
cursor_y = cursor_y.saturating_add(entry.y_extent);
}
}
let mut carried_back: Vec<usize> = Vec::new();
for item_2d in unplaced_2d {
if let Some(stack_index) = parse_stack_index(&item_2d.name) {
carried_back.push(stack_index);
} else {
debug_assert!(false, "place_into_sheet returned unexpected leftover name");
}
}
let mut next_pending: Vec<Stack> =
Vec::with_capacity(carry_indices.len() + carried_back.len());
for index in &carry_indices {
next_pending.push(pending[*index].clone());
}
for index in &carried_back {
next_pending.push(pending[*index].clone());
}
if layout.is_empty() {
debug_assert!(false, "place_into_sheet placed nothing despite pre-filtering");
pending = next_pending;
break;
}
bin_placements.push((bin_index, layout));
pending = next_pending;
}
for stack in pending {
for entry in stack.items {
leftover_items.push(entry.item);
}
}
let bin_count = bin_placements.len();
let metrics = SolverMetrics3D {
iterations: 1,
explored_states: 0,
extreme_points_generated: 0,
branch_and_bound_nodes: 0,
notes: vec![format!(
"column_building: {bin_count} bin(s), {} unplaced item(s)",
leftover_items.len()
)],
};
build_solution("column_building", &problem.bins, bin_placements, leftover_items, metrics, false)
}
fn pick_flat_orientation(item: ItemInstance3D, bins: &[Bin3D]) -> FlatItem {
let mut chosen: Option<(Rotation3D, u32, u32, u32)> = None;
let mut fallback: Option<(Rotation3D, u32, u32, u32)> = None;
for (rotation, x_extent, y_extent, z_extent) in item.orientations() {
let replace_fallback = match fallback {
None => true,
Some((_, _, current_y, _)) => y_extent < current_y,
};
if replace_fallback {
fallback = Some((rotation, x_extent, y_extent, z_extent));
}
if !bins
.iter()
.any(|bin| x_extent <= bin.width && y_extent <= bin.height && z_extent <= bin.depth)
{
continue;
}
let replace = match chosen {
None => true,
Some((_, _, current_y, _)) => y_extent < current_y,
};
if replace {
chosen = Some((rotation, x_extent, y_extent, z_extent));
}
}
debug_assert!(chosen.is_some(), "validated item `{}` has no allowed rotation", item.name);
let (flat_rotation, flat_x_extent, flat_y_extent, flat_z_extent) =
chosen.or(fallback).unwrap_or((Rotation3D::Xyz, item.width, item.height, item.depth));
let flat_area = surface_area_u64(flat_x_extent, flat_z_extent);
FlatItem { item, flat_rotation, flat_x_extent, flat_y_extent, flat_z_extent, flat_area }
}
struct StackMatch {
index: usize,
rotation: ChosenRotation,
}
#[derive(Debug, Clone, Copy)]
struct ChosenRotation {
rotation: Rotation3D,
x_extent: u32,
y_extent: u32,
z_extent: u32,
}
fn pick_stack_for(stacks: &[Stack], flat: &FlatItem, bin_height_cap: u32) -> Option<StackMatch> {
let mut best: Option<(StackMatch, u32)> = None;
for (index, stack) in stacks.iter().enumerate() {
let Some(chosen) = matching_rotation(&flat.item, stack.footprint_x, stack.footprint_z)
else {
continue;
};
if stack.accumulated_height.saturating_add(chosen.y_extent) > bin_height_cap {
continue;
}
let accept = match &best {
None => true,
Some((_, best_height)) => stack.accumulated_height < *best_height,
};
if accept {
best = Some((StackMatch { index, rotation: chosen }, stack.accumulated_height));
}
}
best.map(|(m, _)| m)
}
fn matching_rotation(
item: &ItemInstance3D,
target_x: u32,
target_z: u32,
) -> Option<ChosenRotation> {
for (rotation, x_extent, y_extent, z_extent) in item.orientations() {
if x_extent == target_x && z_extent == target_z {
return Some(ChosenRotation { rotation, x_extent, y_extent, z_extent });
}
}
None
}
fn push_onto_stack(stack: &mut Stack, flat: &FlatItem, rotation: ChosenRotation) {
stack.accumulated_height = stack.accumulated_height.saturating_add(rotation.y_extent);
stack.items.push(StackEntry {
item: flat.item.clone(),
rotation: rotation.rotation,
y_extent: rotation.y_extent,
x_extent: rotation.x_extent,
z_extent: rotation.z_extent,
});
}
fn stack_fits_bin(stack: &Stack, bin: &Bin3D) -> bool {
stack.footprint_x <= bin.width
&& stack.footprint_z <= bin.depth
&& stack.accumulated_height <= bin.height
}
fn select_next_bin(
problem: &ThreeDProblem,
bin_quantity_used: &[usize],
pending: &[Stack],
) -> Option<usize> {
let mut best: Option<(usize, u64)> = None;
for (bin_index, bin) in problem.bins.iter().enumerate() {
if let Some(cap) = bin.quantity
&& bin_quantity_used[bin_index] >= cap
{
continue;
}
if !pending.iter().any(|stack| stack_fits_bin(stack, bin)) {
continue;
}
let volume = volume_u64(bin.width, bin.height, bin.depth);
best = match best {
None => Some((bin_index, volume)),
Some((current_index, current_volume)) => {
if volume < current_volume {
Some((bin_index, volume))
} else {
Some((current_index, current_volume))
}
}
};
}
best.map(|(index, _)| index)
}
fn parse_stack_index(name: &str) -> Option<usize> {
name.strip_prefix("__stack_")
.and_then(|rest| rest.strip_suffix("__"))
.and_then(|s| s.parse::<usize>().ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::three_d::model::{Bin3D, BoxDemand3D, RotationMask3D, ThreeDProblem};
fn single_box_problem() -> ThreeDProblem {
ThreeDProblem {
bins: vec![Bin3D {
name: "b".into(),
width: 10,
height: 10,
depth: 10,
cost: 1.0,
quantity: None,
}],
demands: vec![BoxDemand3D {
name: "a".into(),
width: 4,
height: 4,
depth: 4,
quantity: 1,
allowed_rotations: RotationMask3D::ALL,
}],
}
}
#[test]
fn column_building_places_single_box() {
let problem = single_box_problem();
let solution = solve_column_building(&problem, &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
assert_eq!(solution.bin_count, 1);
assert_eq!(solution.layouts[0].placements.len(), 1);
}
#[test]
fn column_building_algorithm_name_is_column_building() {
let problem = single_box_problem();
let solution = solve_column_building(&problem, &ThreeDOptions::default()).expect("solve");
assert_eq!(solution.algorithm, "column_building");
assert!(!solution.guillotine);
}
#[test]
fn column_building_opens_second_bin_when_full() {
let problem = ThreeDProblem {
bins: vec![Bin3D {
name: "b".into(),
width: 5,
height: 5,
depth: 5,
cost: 1.0,
quantity: None,
}],
demands: vec![BoxDemand3D {
name: "a".into(),
width: 5,
height: 5,
depth: 5,
quantity: 2,
allowed_rotations: RotationMask3D::XYZ,
}],
};
let solution = solve_column_building(&problem, &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
assert_eq!(solution.bin_count, 2);
}
#[test]
fn column_building_chooses_a_bin_feasible_flat_orientation() {
let problem = ThreeDProblem {
bins: vec![Bin3D {
name: "b".into(),
width: 1,
height: 6,
depth: 1,
cost: 1.0,
quantity: None,
}],
demands: vec![BoxDemand3D {
name: "a".into(),
width: 6,
height: 1,
depth: 1,
quantity: 1,
allowed_rotations: RotationMask3D::ALL,
}],
};
let solution = solve_column_building(&problem, &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
assert_eq!(solution.layouts.len(), 1);
let placement = &solution.layouts[0].placements[0];
assert_eq!((placement.width, placement.height, placement.depth), (1, 6, 1));
}
#[test]
fn column_building_stacks_share_xz_and_grow_monotonically_in_y() {
let problem = ThreeDProblem {
bins: vec![Bin3D {
name: "tall".into(),
width: 4,
height: 20,
depth: 4,
cost: 1.0,
quantity: None,
}],
demands: vec![BoxDemand3D {
name: "unit".into(),
width: 4,
height: 3,
depth: 4,
quantity: 4,
allowed_rotations: RotationMask3D::XYZ,
}],
};
let solution = solve_column_building(&problem, &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
assert_eq!(solution.bin_count, 1);
let placements = &solution.layouts[0].placements;
assert_eq!(placements.len(), 4);
let mut by_column: std::collections::BTreeMap<(u32, u32), Vec<&Placement3D>> =
std::collections::BTreeMap::new();
for placement in placements {
by_column.entry((placement.x, placement.z)).or_default().push(placement);
}
assert_eq!(by_column.len(), 1, "all four items should share one column");
for column in by_column.values() {
let mut sorted = column.clone();
sorted.sort_by_key(|placement| placement.y);
let mut cursor: u32 = 0;
for placement in sorted {
assert_eq!(placement.y, cursor, "stack must be contiguous in y");
cursor = cursor.saturating_add(placement.height);
}
assert!(cursor <= 20, "column must fit the bin height");
}
}
#[test]
fn column_building_heterogeneous_footprints_degenerate_to_one_stack_per_item() {
let problem = ThreeDProblem {
bins: vec![Bin3D {
name: "b".into(),
width: 10,
height: 10,
depth: 10,
cost: 1.0,
quantity: None,
}],
demands: vec![
BoxDemand3D {
name: "a".into(),
width: 4,
height: 3,
depth: 5,
quantity: 1,
allowed_rotations: RotationMask3D::XYZ,
},
BoxDemand3D {
name: "b".into(),
width: 3,
height: 3,
depth: 4,
quantity: 1,
allowed_rotations: RotationMask3D::XYZ,
},
],
};
let solution = solve_column_building(&problem, &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
assert_eq!(solution.bin_count, 1);
assert_eq!(solution.layouts[0].placements.len(), 2);
for placement in &solution.layouts[0].placements {
assert_eq!(placement.y, 0, "heterogeneous footprints should not stack");
}
}
}