use serde::{Deserialize, Serialize};
use crate::{BinPackingError, Result};
pub const MAX_DIMENSION_3D: u32 = 1 << 15;
pub const MAX_BIN_COUNT_3D: usize = 1 << 15;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThreeDAlgorithm {
#[default]
Auto,
ExtremePoints,
ExtremePointsResidualSpace,
ExtremePointsFreeVolume,
ExtremePointsBottomLeftBack,
ExtremePointsContactPoint,
ExtremePointsEuclidean,
#[serde(rename = "guillotine_3d")]
Guillotine3D,
#[serde(rename = "guillotine_3d_best_short_side_fit")]
Guillotine3DBestShortSideFit,
#[serde(rename = "guillotine_3d_best_long_side_fit")]
Guillotine3DBestLongSideFit,
#[serde(rename = "guillotine_3d_shorter_leftover_axis")]
Guillotine3DShorterLeftoverAxis,
#[serde(rename = "guillotine_3d_longer_leftover_axis")]
Guillotine3DLongerLeftoverAxis,
#[serde(rename = "guillotine_3d_min_volume_split")]
Guillotine3DMinVolumeSplit,
#[serde(rename = "guillotine_3d_max_volume_split")]
Guillotine3DMaxVolumeSplit,
LayerBuilding,
LayerBuildingMaxRects,
LayerBuildingSkyline,
LayerBuildingGuillotine,
LayerBuildingShelf,
WallBuilding,
ColumnBuilding,
DeepestBottomLeft,
DeepestBottomLeftFill,
FirstFitDecreasingVolume,
BestFitDecreasingVolume,
MultiStart,
Grasp,
LocalSearch,
BranchAndBound,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Bin3D {
pub name: String,
pub width: u32,
pub height: u32,
pub depth: u32,
#[serde(default = "default_bin_cost")]
pub cost: f64,
#[serde(default)]
pub quantity: Option<usize>,
}
fn default_bin_cost() -> f64 {
1.0
}
fn default_rotation_mask() -> RotationMask3D {
RotationMask3D::ALL
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoxDemand3D {
pub name: String,
pub width: u32,
pub height: u32,
pub depth: u32,
pub quantity: usize,
#[serde(default = "default_rotation_mask")]
pub allowed_rotations: RotationMask3D,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Rotation3D {
Xyz,
Xzy,
Yxz,
Yzx,
Zxy,
Zyx,
}
impl Rotation3D {
pub fn apply(self, width: u32, height: u32, depth: u32) -> (u32, u32, u32) {
match self {
Self::Xyz => (width, height, depth),
Self::Xzy => (width, depth, height),
Self::Yxz => (height, width, depth),
Self::Yzx => (height, depth, width),
Self::Zxy => (depth, width, height),
Self::Zyx => (depth, height, width),
}
}
pub(crate) fn bit(self) -> u8 {
match self {
Self::Xyz => 0,
Self::Xzy => 1,
Self::Yxz => 2,
Self::Yzx => 3,
Self::Zxy => 4,
Self::Zyx => 5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RotationMask3D(u8);
impl RotationMask3D {
pub const XYZ: Self = Self(1 << 0);
pub const XZY: Self = Self(1 << 1);
pub const YXZ: Self = Self(1 << 2);
pub const YZX: Self = Self(1 << 3);
pub const ZXY: Self = Self(1 << 4);
pub const ZYX: Self = Self(1 << 5);
pub const ALL: Self = Self(0b00111111);
pub const UPRIGHT: Self = Self(0b00100001);
pub const NONE: Self = Self(0);
pub fn contains(self, rotation: Rotation3D) -> bool {
(self.0 & (1 << rotation.bit())) != 0
}
pub fn is_empty(self) -> bool {
self.0 == 0
}
pub fn iter(self) -> impl Iterator<Item = Rotation3D> {
const ALL: [Rotation3D; 6] = [
Rotation3D::Xyz,
Rotation3D::Xzy,
Rotation3D::Yxz,
Rotation3D::Yzx,
Rotation3D::Zxy,
Rotation3D::Zyx,
];
ALL.into_iter().filter(move |rot| self.contains(*rot))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Placement3D {
pub name: String,
pub x: u32,
pub y: u32,
pub z: u32,
pub width: u32,
pub height: u32,
pub depth: u32,
pub rotation: Rotation3D,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BinLayout3D {
pub bin_name: String,
pub width: u32,
pub height: u32,
pub depth: u32,
pub cost: f64,
pub placements: Vec<Placement3D>,
pub used_volume: u64,
pub waste_volume: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BinRequirement3D {
pub bin_name: String,
pub bin_width: u32,
pub bin_height: u32,
pub bin_depth: u32,
pub cost: f64,
pub available_quantity: Option<usize>,
pub used_quantity: usize,
pub required_quantity: usize,
pub additional_quantity_needed: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SolverMetrics3D {
pub iterations: usize,
pub explored_states: usize,
pub extreme_points_generated: usize,
pub branch_and_bound_nodes: usize,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThreeDSolution {
pub algorithm: String,
pub exact: bool,
pub lower_bound: Option<f64>,
pub guillotine: bool,
pub bin_count: usize,
pub total_waste_volume: u64,
pub total_cost: f64,
pub layouts: Vec<BinLayout3D>,
#[serde(default)]
pub bin_requirements: Vec<BinRequirement3D>,
pub unplaced: Vec<BoxDemand3D>,
pub metrics: SolverMetrics3D,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThreeDProblem {
pub bins: Vec<Bin3D>,
pub demands: Vec<BoxDemand3D>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreeDOptions {
#[serde(default)]
pub algorithm: ThreeDAlgorithm,
#[serde(default = "default_multistart_runs")]
pub multistart_runs: usize,
#[serde(default = "default_improvement_rounds")]
pub improvement_rounds: usize,
#[serde(default = "default_beam_width")]
pub beam_width: usize,
#[serde(default = "default_auto_exact_max_types")]
pub auto_exact_max_types: usize,
#[serde(default = "default_auto_exact_max_quantity")]
pub auto_exact_max_quantity: usize,
#[serde(default = "default_branch_and_bound_node_limit")]
pub branch_and_bound_node_limit: usize,
#[serde(default)]
pub guillotine_required: bool,
#[serde(default)]
pub seed: Option<u64>,
}
impl Default for ThreeDOptions {
fn default() -> Self {
Self {
algorithm: ThreeDAlgorithm::Auto,
multistart_runs: default_multistart_runs(),
improvement_rounds: default_improvement_rounds(),
beam_width: default_beam_width(),
auto_exact_max_types: default_auto_exact_max_types(),
auto_exact_max_quantity: default_auto_exact_max_quantity(),
branch_and_bound_node_limit: default_branch_and_bound_node_limit(),
guillotine_required: false,
seed: None,
}
}
}
impl ThreeDSolution {
#[allow(dead_code)]
pub(crate) fn is_better_than(&self, other: &Self) -> bool {
(
self.unplaced.len(),
self.bin_count,
self.total_waste_volume,
OrderedFloat3D(self.total_cost),
) < (
other.unplaced.len(),
other.bin_count,
other.total_waste_volume,
OrderedFloat3D(other.total_cost),
)
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq)]
struct OrderedFloat3D(f64);
impl Eq for OrderedFloat3D {}
impl PartialOrd for OrderedFloat3D {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for OrderedFloat3D {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&other.0)
}
}
fn default_multistart_runs() -> usize {
12
}
fn default_improvement_rounds() -> usize {
24
}
fn default_beam_width() -> usize {
8
}
fn default_auto_exact_max_types() -> usize {
8
}
fn default_auto_exact_max_quantity() -> usize {
32
}
fn default_branch_and_bound_node_limit() -> usize {
1_000_000
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ItemInstance3D {
pub(crate) demand_index: usize,
pub(crate) name: String,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) depth: u32,
pub(crate) allowed_rotations: RotationMask3D,
}
impl ItemInstance3D {
#[allow(dead_code)]
pub(crate) fn orientations(&self) -> impl Iterator<Item = (Rotation3D, u32, u32, u32)> + '_ {
let mut seen: Vec<(u32, u32, u32)> = Vec::with_capacity(6);
self.allowed_rotations.iter().filter_map(move |rotation| {
let extents = rotation.apply(self.width, self.height, self.depth);
if seen.contains(&extents) {
None
} else {
seen.push(extents);
Some((rotation, extents.0, extents.1, extents.2))
}
})
}
}
impl ThreeDProblem {
pub(crate) fn validate(&self) -> Result<()> {
if self.bins.is_empty() {
return Err(BinPackingError::InvalidInput(
"at least one bin entry is required".to_string(),
));
}
if self.demands.is_empty() {
return Err(BinPackingError::InvalidInput(
"at least one box demand entry is required".to_string(),
));
}
for bin in &self.bins {
if bin.width == 0 || bin.height == 0 || bin.depth == 0 {
return Err(BinPackingError::InvalidInput(format!(
"bin `{}` must have positive width, height, and depth",
bin.name
)));
}
if bin.width > MAX_DIMENSION_3D
|| bin.height > MAX_DIMENSION_3D
|| bin.depth > MAX_DIMENSION_3D
{
return Err(BinPackingError::InvalidInput(format!(
"bin `{}` dimensions exceed the supported maximum of {}",
bin.name, MAX_DIMENSION_3D
)));
}
if !bin.cost.is_finite() || bin.cost < 0.0 {
return Err(BinPackingError::InvalidInput(format!(
"bin `{}` must have a finite non-negative cost",
bin.name
)));
}
if let Some(quantity) = bin.quantity
&& quantity == 0
{
return Err(BinPackingError::InvalidInput(format!(
"bin `{}` quantity, if set, must be positive",
bin.name
)));
}
}
for demand in &self.demands {
if demand.width == 0 || demand.height == 0 || demand.depth == 0 {
return Err(BinPackingError::InvalidInput(format!(
"demand `{}` must have positive width, height, and depth",
demand.name
)));
}
if demand.width > MAX_DIMENSION_3D
|| demand.height > MAX_DIMENSION_3D
|| demand.depth > MAX_DIMENSION_3D
{
return Err(BinPackingError::InvalidInput(format!(
"demand `{}` dimensions exceed the supported maximum of {}",
demand.name, MAX_DIMENSION_3D
)));
}
if demand.quantity == 0 {
return Err(BinPackingError::InvalidInput(format!(
"demand `{}` must have positive quantity",
demand.name
)));
}
if demand.allowed_rotations.is_empty() {
return Err(BinPackingError::InvalidInput(format!(
"demand `{}` must allow at least one rotation",
demand.name
)));
}
}
Ok(())
}
pub(crate) fn ensure_feasible_demands(&self) -> Result<()> {
for demand in &self.demands {
let feasible = self.bins.iter().any(|bin| {
demand.allowed_rotations.iter().any(|rotation| {
let (x, y, z) = rotation.apply(demand.width, demand.height, demand.depth);
bin.width >= x && bin.height >= y && bin.depth >= z
})
});
if !feasible {
return Err(BinPackingError::Infeasible3D {
item: demand.name.clone(),
width: demand.width,
height: demand.height,
depth: demand.depth,
});
}
}
Ok(())
}
#[allow(dead_code)]
pub(crate) fn expanded_items(&self) -> Vec<ItemInstance3D> {
let mut items = Vec::new();
for (index, demand) in self.demands.iter().enumerate() {
for _ in 0..demand.quantity {
items.push(ItemInstance3D {
demand_index: index,
name: demand.name.clone(),
width: demand.width,
height: demand.height,
depth: demand.depth,
allowed_rotations: demand.allowed_rotations,
});
}
}
items
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rotation3d_serializes_as_snake_case() {
let value = serde_json::to_value(Rotation3D::Zxy).expect("serialize");
assert_eq!(value, json!("zxy"));
}
#[test]
fn algorithm_serializes_with_explicit_renames_for_digit_variants() {
let cases = [
(ThreeDAlgorithm::Auto, "auto"),
(ThreeDAlgorithm::ExtremePoints, "extreme_points"),
(ThreeDAlgorithm::Guillotine3D, "guillotine_3d"),
(ThreeDAlgorithm::Guillotine3DBestShortSideFit, "guillotine_3d_best_short_side_fit"),
(ThreeDAlgorithm::LayerBuildingMaxRects, "layer_building_max_rects"),
(ThreeDAlgorithm::DeepestBottomLeftFill, "deepest_bottom_left_fill"),
(ThreeDAlgorithm::BranchAndBound, "branch_and_bound"),
];
for (variant, expected) in cases {
let value = serde_json::to_value(variant).expect("serialize");
assert_eq!(value, json!(expected), "{:?}", variant);
let parsed: ThreeDAlgorithm =
serde_json::from_value(json!(expected)).expect("deserialize");
assert_eq!(parsed, variant);
}
}
#[test]
fn rotation_mask_all_contains_every_rotation() {
let mask = RotationMask3D::ALL;
for rot in [
Rotation3D::Xyz,
Rotation3D::Xzy,
Rotation3D::Yxz,
Rotation3D::Yzx,
Rotation3D::Zxy,
Rotation3D::Zyx,
] {
assert!(mask.contains(rot), "ALL should contain {:?}", rot);
}
}
#[test]
fn rotation_mask_upright_only_keeps_y_axis() {
let upright = RotationMask3D::UPRIGHT;
assert!(upright.contains(Rotation3D::Xyz));
assert!(upright.contains(Rotation3D::Zyx));
assert!(!upright.contains(Rotation3D::Xzy));
assert!(!upright.contains(Rotation3D::Yxz));
assert!(!upright.contains(Rotation3D::Yzx));
assert!(!upright.contains(Rotation3D::Zxy));
}
#[test]
fn rotation_mask_none_is_empty() {
let none = RotationMask3D::NONE;
for rot in [
Rotation3D::Xyz,
Rotation3D::Xzy,
Rotation3D::Yxz,
Rotation3D::Yzx,
Rotation3D::Zxy,
Rotation3D::Zyx,
] {
assert!(!none.contains(rot));
}
}
#[test]
fn rotation3d_apply_zxy_maps_input_dims_correctly() {
let (x, y, z) = Rotation3D::Zxy.apply(3, 5, 7);
assert_eq!((x, y, z), (7, 3, 5));
}
#[test]
fn rotation3d_apply_xyz_is_identity() {
let (x, y, z) = Rotation3D::Xyz.apply(3, 5, 7);
assert_eq!((x, y, z), (3, 5, 7));
}
#[test]
fn rotation3d_apply_zyx_swaps_x_and_z() {
let (x, y, z) = Rotation3D::Zyx.apply(3, 5, 7);
assert_eq!((x, y, z), (7, 5, 3));
}
#[test]
fn validate_rejects_empty_bins() {
let problem = ThreeDProblem { bins: vec![], demands: vec![sample_demand("a", 1, 1, 1, 1)] };
let err = problem.validate().expect_err("should reject");
assert!(
matches!(&err, crate::BinPackingError::InvalidInput(msg) if msg.contains("bin")),
"unexpected error: {err:?}",
);
}
#[test]
fn validate_rejects_empty_demands() {
let problem = ThreeDProblem { bins: vec![sample_bin("b", 10, 10, 10)], demands: vec![] };
let err = problem.validate().expect_err("should reject");
assert!(matches!(err, crate::BinPackingError::InvalidInput(_)));
}
#[test]
fn validate_rejects_zero_dimension_bin() {
let problem = ThreeDProblem {
bins: vec![sample_bin("b", 0, 10, 10)],
demands: vec![sample_demand("a", 1, 1, 1, 1)],
};
assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
}
#[test]
fn validate_rejects_oversized_dimension() {
let oversize = MAX_DIMENSION_3D + 1;
let problem = ThreeDProblem {
bins: vec![sample_bin("b", oversize, 10, 10)],
demands: vec![sample_demand("a", 1, 1, 1, 1)],
};
assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
}
#[test]
fn validate_rejects_non_finite_cost() {
let mut bin = sample_bin("b", 10, 10, 10);
bin.cost = f64::NAN;
let problem =
ThreeDProblem { bins: vec![bin], demands: vec![sample_demand("a", 1, 1, 1, 1)] };
assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
}
#[test]
fn validate_rejects_negative_cost() {
let mut bin = sample_bin("b", 10, 10, 10);
bin.cost = -1.0;
let problem =
ThreeDProblem { bins: vec![bin], demands: vec![sample_demand("a", 1, 1, 1, 1)] };
assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
}
#[test]
fn validate_rejects_zero_quantity_demand() {
let problem = ThreeDProblem {
bins: vec![sample_bin("b", 10, 10, 10)],
demands: vec![sample_demand("a", 1, 1, 1, 0)],
};
assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
}
#[test]
fn validate_rejects_empty_rotation_mask() {
let mut demand = sample_demand("a", 1, 1, 1, 1);
demand.allowed_rotations = RotationMask3D::NONE;
let problem =
ThreeDProblem { bins: vec![sample_bin("b", 10, 10, 10)], demands: vec![demand] };
assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
}
#[test]
fn validate_accepts_well_formed_problem() {
let problem = ThreeDProblem {
bins: vec![sample_bin("b", 10, 10, 10)],
demands: vec![sample_demand("a", 2, 3, 4, 1)],
};
problem.validate().expect("should accept");
}
#[test]
fn ensure_feasible_demands_rejects_oversize_item() {
let problem = ThreeDProblem {
bins: vec![sample_bin("b", 5, 5, 5)],
demands: vec![sample_demand("a", 6, 6, 6, 1)],
};
problem.validate().expect("validate ok");
let err = problem.ensure_feasible_demands().expect_err("should reject");
assert!(
matches!(&err, crate::BinPackingError::Infeasible3D { item, .. } if item == "a"),
"unexpected error: {err:?}",
);
}
#[test]
fn ensure_feasible_demands_uses_rotation() {
let problem = ThreeDProblem {
bins: vec![sample_bin("b", 1, 6, 1)],
demands: vec![sample_demand("a", 6, 1, 1, 1)],
};
problem.validate().expect("validate ok");
problem.ensure_feasible_demands().expect("should accept via rotation");
}
#[test]
fn expanded_items_yields_one_per_quantity() {
let problem = ThreeDProblem {
bins: vec![sample_bin("b", 10, 10, 10)],
demands: vec![sample_demand("a", 2, 3, 4, 5), sample_demand("c", 1, 1, 1, 2)],
};
let items = problem.expanded_items();
assert_eq!(items.len(), 7);
assert_eq!(items.iter().filter(|item| item.name == "a").count(), 5);
assert_eq!(items.iter().filter(|item| item.name == "c").count(), 2);
}
#[test]
fn three_d_is_better_than_tie_breaks_on_each_key() {
let base = sample_solution(0, 1, 0, 1.0);
let more_unplaced =
ThreeDSolution { unplaced: vec![sample_demand("u", 1, 1, 1, 1)], ..base.clone() };
assert!(base.is_better_than(&more_unplaced));
assert!(!more_unplaced.is_better_than(&base));
let more_bins = ThreeDSolution { bin_count: 2, ..base.clone() };
assert!(base.is_better_than(&more_bins));
let more_waste = ThreeDSolution { total_waste_volume: 100, ..base.clone() };
assert!(base.is_better_than(&more_waste));
let more_cost = ThreeDSolution { total_cost: base.total_cost + 1.0, ..base.clone() };
assert!(base.is_better_than(&more_cost));
assert!(!base.is_better_than(&base));
}
fn sample_solution(
unplaced_count: usize,
bin_count: usize,
waste: u64,
cost: f64,
) -> ThreeDSolution {
ThreeDSolution {
algorithm: "test".to_string(),
exact: false,
lower_bound: None,
guillotine: false,
bin_count,
total_waste_volume: waste,
total_cost: cost,
layouts: Vec::new(),
bin_requirements: Vec::new(),
unplaced: (0..unplaced_count)
.map(|i| sample_demand(&format!("u{i}"), 1, 1, 1, 1))
.collect(),
metrics: SolverMetrics3D::default(),
}
}
fn sample_bin(name: &str, w: u32, h: u32, d: u32) -> Bin3D {
Bin3D { name: name.to_string(), width: w, height: h, depth: d, cost: 1.0, quantity: None }
}
fn sample_demand(name: &str, w: u32, h: u32, d: u32, qty: usize) -> BoxDemand3D {
BoxDemand3D {
name: name.to_string(),
width: w,
height: h,
depth: d,
quantity: qty,
allowed_rotations: RotationMask3D::ALL,
}
}
}