use std::collections::BTreeSet;
use super::common::{placement_feasible, surface_area_u64, volume_u64};
use super::model::{
Bin3D, BinLayout3D, BoxDemand3D, ItemInstance3D, MAX_BIN_COUNT_3D, Placement3D, Rotation3D,
SolverMetrics3D, ThreeDOptions, ThreeDProblem, ThreeDSolution,
};
use crate::{BinPackingError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ExtremePointsScoring {
VolumeFitResidual,
ResidualSpace,
FreeVolume,
BottomLeftBack,
ContactPoint,
Euclidean,
}
pub(super) struct BinState {
pub(super) bin_index: usize,
pub(super) placements: Vec<Placement3D>,
pub(super) eps: BTreeSet<(u32, u32, u32)>,
pub(super) extreme_points_generated: usize,
}
impl BinState {
pub(super) fn new(bin_index: usize) -> Self {
let mut eps = BTreeSet::new();
eps.insert((0, 0, 0));
Self { bin_index, placements: Vec::new(), eps, extreme_points_generated: 1 }
}
}
pub(super) fn try_place_into_bin(
state: &mut BinState,
bin: &Bin3D,
item: &ItemInstance3D,
scoring: ExtremePointsScoring,
) -> Option<Placement3D> {
let placed_volume: u64 = state
.placements
.iter()
.map(|placement| volume_u64(placement.width, placement.height, placement.depth))
.sum();
let bin_volume = volume_u64(bin.width, bin.height, bin.depth);
let ep_snapshot: Vec<(u32, u32, u32)> = state.eps.iter().copied().collect();
let mut best: Option<Candidate> = None;
for (rotation, w, h, d) in item.orientations() {
for &(ep_z, ep_y, ep_x) in &ep_snapshot {
if !placement_feasible(
ep_x,
ep_y,
ep_z,
w,
h,
d,
bin.width,
bin.height,
bin.depth,
&state.placements,
) {
continue;
}
let score = score_candidate(
scoring,
bin,
&state.placements,
placed_volume,
bin_volume,
ep_x,
ep_y,
ep_z,
w,
h,
d,
);
let tiebreak = (ep_y, ep_x, ep_z, rotation_ord(rotation), item.demand_index);
let candidate = Candidate { score, tiebreak, rotation, ep_x, ep_y, ep_z, w, h, d };
best = match best {
None => Some(candidate),
Some(current) => {
if candidate.is_better_than(¤t) {
Some(candidate)
} else {
Some(current)
}
}
};
}
}
let chosen = best?;
let placement = Placement3D {
name: item.name.clone(),
x: chosen.ep_x,
y: chosen.ep_y,
z: chosen.ep_z,
width: chosen.w,
height: chosen.h,
depth: chosen.d,
rotation: chosen.rotation,
};
state.eps.remove(&(chosen.ep_z, chosen.ep_y, chosen.ep_x));
let generated = project_extreme_points(&placement, &state.placements, bin, &mut state.eps);
state.extreme_points_generated = state.extreme_points_generated.saturating_add(generated);
state.placements.push(placement.clone());
Some(placement)
}
pub(super) fn solve_with_scoring_on_items(
problem: &ThreeDProblem,
items: Vec<ItemInstance3D>,
_options: &ThreeDOptions,
scoring: ExtremePointsScoring,
name: &'static str,
) -> Result<ThreeDSolution> {
let mut states: Vec<BinState> = Vec::new();
let mut bin_quantity_used: Vec<usize> = vec![0; problem.bins.len()];
let mut unplaced: Vec<ItemInstance3D> = Vec::new();
let mut total_extreme_points_generated: usize = 0;
for item in items {
if place_item_multi_bin(
problem,
&item,
&mut states,
&mut bin_quantity_used,
scoring,
&mut total_extreme_points_generated,
)? {
continue;
}
unplaced.push(item);
}
let bin_count = states.len();
if bin_count > MAX_BIN_COUNT_3D {
return Err(BinPackingError::InvalidInput(format!(
"solution would consume {bin_count} bins, exceeding the supported maximum of {MAX_BIN_COUNT_3D}"
)));
}
let mut layouts: Vec<BinLayout3D> = states
.into_iter()
.map(|state| {
let bin = &problem.bins[state.bin_index];
let used_volume: u64 = state
.placements
.iter()
.map(|placement| volume_u64(placement.width, placement.height, placement.depth))
.sum();
let bin_volume = volume_u64(bin.width, bin.height, bin.depth);
debug_assert!(used_volume <= bin_volume, "used > bin volume in `{}`", bin.name);
BinLayout3D {
bin_name: bin.name.clone(),
width: bin.width,
height: bin.height,
depth: bin.depth,
cost: bin.cost,
placements: state.placements,
used_volume,
waste_volume: bin_volume.saturating_sub(used_volume),
}
})
.collect();
layouts.sort_by(|a, b| {
b.used_volume.cmp(&a.used_volume).then_with(|| a.bin_name.cmp(&b.bin_name))
});
let total_waste_volume: u64 = layouts.iter().map(|layout| layout.waste_volume).sum();
let total_cost: f64 = layouts.iter().map(|layout| layout.cost).sum();
let mut unplaced_demands: Vec<BoxDemand3D> = unplaced
.into_iter()
.map(|item| BoxDemand3D {
name: item.name,
width: item.width,
height: item.height,
depth: item.depth,
quantity: 1,
allowed_rotations: item.allowed_rotations,
})
.collect();
unplaced_demands.sort_by(|left, right| {
let left_volume = volume_u64(left.width, left.height, left.depth);
let right_volume = volume_u64(right.width, right.height, right.depth);
right_volume.cmp(&left_volume)
});
let metrics = SolverMetrics3D {
iterations: 1,
explored_states: 0,
extreme_points_generated: total_extreme_points_generated,
branch_and_bound_nodes: 0,
notes: vec![format!("{name}: {bin_count} bin(s), {} unplaced", unplaced_demands.len())],
};
Ok(ThreeDSolution {
algorithm: name.to_string(),
exact: false,
lower_bound: None,
guillotine: false,
bin_count: layouts.len(),
total_waste_volume,
total_cost,
layouts,
bin_requirements: Vec::new(),
unplaced: unplaced_demands,
metrics,
})
}
pub(super) fn solve_extreme_points(
problem: &ThreeDProblem,
options: &ThreeDOptions,
) -> Result<ThreeDSolution> {
run_variant(problem, options, ExtremePointsScoring::VolumeFitResidual, "extreme_points")
}
pub(super) fn solve_extreme_points_residual_space(
problem: &ThreeDProblem,
options: &ThreeDOptions,
) -> Result<ThreeDSolution> {
run_variant(
problem,
options,
ExtremePointsScoring::ResidualSpace,
"extreme_points_residual_space",
)
}
pub(super) fn solve_extreme_points_free_volume(
problem: &ThreeDProblem,
options: &ThreeDOptions,
) -> Result<ThreeDSolution> {
run_variant(problem, options, ExtremePointsScoring::FreeVolume, "extreme_points_free_volume")
}
pub(super) fn solve_extreme_points_bottom_left_back(
problem: &ThreeDProblem,
options: &ThreeDOptions,
) -> Result<ThreeDSolution> {
run_variant(
problem,
options,
ExtremePointsScoring::BottomLeftBack,
"extreme_points_bottom_left_back",
)
}
pub(super) fn solve_extreme_points_contact_point(
problem: &ThreeDProblem,
options: &ThreeDOptions,
) -> Result<ThreeDSolution> {
run_variant(
problem,
options,
ExtremePointsScoring::ContactPoint,
"extreme_points_contact_point",
)
}
pub(super) fn solve_extreme_points_euclidean(
problem: &ThreeDProblem,
options: &ThreeDOptions,
) -> Result<ThreeDSolution> {
run_variant(problem, options, ExtremePointsScoring::Euclidean, "extreme_points_euclidean")
}
fn run_variant(
problem: &ThreeDProblem,
options: &ThreeDOptions,
scoring: ExtremePointsScoring,
name: &'static str,
) -> Result<ThreeDSolution> {
let mut items = problem.expanded_items();
items.sort_by(|a, b| {
let lhs = volume_u64(a.width, a.height, a.depth);
let rhs = volume_u64(b.width, b.height, b.depth);
rhs.cmp(&lhs)
});
solve_with_scoring_on_items(problem, items, options, scoring, name)
}
#[derive(Debug, Clone, Copy)]
struct Candidate {
score: u64,
tiebreak: (u32, u32, u32, u8, usize),
rotation: Rotation3D,
ep_x: u32,
ep_y: u32,
ep_z: u32,
w: u32,
h: u32,
d: u32,
}
impl Candidate {
fn is_better_than(&self, other: &Self) -> bool {
match self.score.cmp(&other.score) {
std::cmp::Ordering::Less => true,
std::cmp::Ordering::Greater => false,
std::cmp::Ordering::Equal => self.tiebreak < other.tiebreak,
}
}
}
fn place_item_multi_bin(
problem: &ThreeDProblem,
item: &ItemInstance3D,
states: &mut Vec<BinState>,
bin_quantity_used: &mut [usize],
scoring: ExtremePointsScoring,
total_extreme_points_generated: &mut usize,
) -> Result<bool> {
for state in states.iter_mut() {
let bin = &problem.bins[state.bin_index];
if try_place_into_bin(state, bin, item, scoring).is_some() {
*total_extreme_points_generated =
total_extreme_points_generated.saturating_add(state.eps.len());
return Ok(true);
}
}
let mut skip_bin_type: Vec<bool> = vec![false; problem.bins.len()];
loop {
let mut best: Option<(usize, u64)> = None;
for (bin_index, bin) in problem.bins.iter().enumerate() {
if skip_bin_type[bin_index] {
continue;
}
if let Some(cap) = bin.quantity
&& bin_quantity_used[bin_index] >= cap
{
continue;
}
if !bin_contains_some_orientation(bin, item) {
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))
}
}
};
}
let Some((bin_index, _)) = best else {
return Ok(false);
};
if states.len() >= MAX_BIN_COUNT_3D {
return Err(BinPackingError::Unsupported(format!(
"3D bin count cap exceeded: opened {} bins, MAX_BIN_COUNT_3D = {MAX_BIN_COUNT_3D}",
states.len()
)));
}
let mut state = BinState::new(bin_index);
*total_extreme_points_generated = total_extreme_points_generated.saturating_add(1);
let bin = &problem.bins[bin_index];
if try_place_into_bin(&mut state, bin, item, scoring).is_some() {
bin_quantity_used[bin_index] = bin_quantity_used[bin_index].saturating_add(1);
*total_extreme_points_generated =
total_extreme_points_generated.saturating_add(state.eps.len());
states.push(state);
return Ok(true);
}
skip_bin_type[bin_index] = true;
}
}
fn bin_contains_some_orientation(bin: &Bin3D, item: &ItemInstance3D) -> bool {
item.orientations().any(|(_, w, h, d)| w <= bin.width && h <= bin.height && d <= bin.depth)
}
fn rotation_ord(rotation: Rotation3D) -> u8 {
match rotation {
Rotation3D::Xyz => 0,
Rotation3D::Xzy => 1,
Rotation3D::Yxz => 2,
Rotation3D::Yzx => 3,
Rotation3D::Zxy => 4,
Rotation3D::Zyx => 5,
}
}
#[allow(clippy::too_many_arguments)]
fn score_candidate(
scoring: ExtremePointsScoring,
bin: &Bin3D,
placements: &[Placement3D],
placed_volume: u64,
bin_volume: u64,
ep_x: u32,
ep_y: u32,
ep_z: u32,
w: u32,
h: u32,
d: u32,
) -> u64 {
match scoring {
ExtremePointsScoring::VolumeFitResidual => {
let item_volume = volume_u64(w, h, d);
bin_volume.saturating_sub(placed_volume).saturating_sub(item_volume)
}
ExtremePointsScoring::ResidualSpace => {
let gap_x = bin.width - ep_x - w;
let gap_y = bin.height - ep_y - h;
let gap_z = bin.depth - ep_z - d;
u64::from(gap_x.min(gap_y).min(gap_z))
}
ExtremePointsScoring::FreeVolume => {
let gap_x = bin.width - ep_x - w;
let gap_y = bin.height - ep_y - h;
let gap_z = bin.depth - ep_z - d;
volume_u64(gap_x, gap_y, gap_z)
}
ExtremePointsScoring::BottomLeftBack => {
(u64::from(ep_y) << 32) | (u64::from(ep_x) << 16) | u64::from(ep_z)
}
ExtremePointsScoring::ContactPoint => {
let contact = contact_point_score(bin, placements, ep_x, ep_y, ep_z, w, h, d);
u64::MAX - contact
}
ExtremePointsScoring::Euclidean => {
let dx = u64::from(ep_x);
let dy = u64::from(ep_y);
let dz = u64::from(ep_z);
dx.saturating_mul(dx)
.saturating_add(dy.saturating_mul(dy))
.saturating_add(dz.saturating_mul(dz))
}
}
}
#[allow(clippy::too_many_arguments)]
fn contact_point_score(
bin: &Bin3D,
placements: &[Placement3D],
ep_x: u32,
ep_y: u32,
ep_z: u32,
w: u32,
h: u32,
d: u32,
) -> u64 {
let x_lo = ep_x;
let x_hi = ep_x + w;
let y_lo = ep_y;
let y_hi = ep_y + h;
let z_lo = ep_z;
let z_hi = ep_z + d;
let mut total: u64 = 0;
if x_lo == 0 {
total = total.saturating_add(surface_area_u64(h, d));
}
if x_hi == bin.width {
total = total.saturating_add(surface_area_u64(h, d));
}
if y_lo == 0 {
total = total.saturating_add(surface_area_u64(w, d));
}
if y_hi == bin.height {
total = total.saturating_add(surface_area_u64(w, d));
}
if z_lo == 0 {
total = total.saturating_add(surface_area_u64(w, h));
}
if z_hi == bin.depth {
total = total.saturating_add(surface_area_u64(w, h));
}
let mut rects_x_lo: Vec<(u32, u32, u32, u32)> = Vec::new();
let mut rects_x_hi: Vec<(u32, u32, u32, u32)> = Vec::new();
let mut rects_y_lo: Vec<(u32, u32, u32, u32)> = Vec::new();
let mut rects_y_hi: Vec<(u32, u32, u32, u32)> = Vec::new();
let mut rects_z_lo: Vec<(u32, u32, u32, u32)> = Vec::new();
let mut rects_z_hi: Vec<(u32, u32, u32, u32)> = Vec::new();
for placement in placements {
let p_x_lo = placement.x;
let p_x_hi = placement.x + placement.width;
let p_y_lo = placement.y;
let p_y_hi = placement.y + placement.height;
let p_z_lo = placement.z;
let p_z_hi = placement.z + placement.depth;
let yz_cand = ((y_lo, y_hi), (z_lo, z_hi));
let xz_cand = ((x_lo, x_hi), (z_lo, z_hi));
let xy_cand = ((x_lo, x_hi), (y_lo, y_hi));
let yz_other = ((p_y_lo, p_y_hi), (p_z_lo, p_z_hi));
let xz_other = ((p_x_lo, p_x_hi), (p_z_lo, p_z_hi));
let xy_other = ((p_x_lo, p_x_hi), (p_y_lo, p_y_hi));
if p_x_hi == x_lo
&& let Some(rect) = intersect_2d(yz_cand.0, yz_cand.1, yz_other.0, yz_other.1)
{
rects_x_lo.push(rect);
}
if p_x_lo == x_hi
&& let Some(rect) = intersect_2d(yz_cand.0, yz_cand.1, yz_other.0, yz_other.1)
{
rects_x_hi.push(rect);
}
if p_y_hi == y_lo
&& let Some(rect) = intersect_2d(xz_cand.0, xz_cand.1, xz_other.0, xz_other.1)
{
rects_y_lo.push(rect);
}
if p_y_lo == y_hi
&& let Some(rect) = intersect_2d(xz_cand.0, xz_cand.1, xz_other.0, xz_other.1)
{
rects_y_hi.push(rect);
}
if p_z_hi == z_lo
&& let Some(rect) = intersect_2d(xy_cand.0, xy_cand.1, xy_other.0, xy_other.1)
{
rects_z_lo.push(rect);
}
if p_z_lo == z_hi
&& let Some(rect) = intersect_2d(xy_cand.0, xy_cand.1, xy_other.0, xy_other.1)
{
rects_z_hi.push(rect);
}
}
total = total.saturating_add(union_area(&rects_x_lo));
total = total.saturating_add(union_area(&rects_x_hi));
total = total.saturating_add(union_area(&rects_y_lo));
total = total.saturating_add(union_area(&rects_y_hi));
total = total.saturating_add(union_area(&rects_z_lo));
total = total.saturating_add(union_area(&rects_z_hi));
total
}
fn intersect_2d(
axis_a: (u32, u32),
axis_b: (u32, u32),
other_axis_a: (u32, u32),
other_axis_b: (u32, u32),
) -> Option<(u32, u32, u32, u32)> {
let la = axis_a.0.max(other_axis_a.0);
let ha = axis_a.1.min(other_axis_a.1);
let lb = axis_b.0.max(other_axis_b.0);
let hb = axis_b.1.min(other_axis_b.1);
if la < ha && lb < hb { Some((la, ha, lb, hb)) } else { None }
}
fn union_area(rects: &[(u32, u32, u32, u32)]) -> u64 {
if rects.is_empty() {
return 0;
}
let mut xs: Vec<u32> = Vec::with_capacity(rects.len() * 2);
let mut ys: Vec<u32> = Vec::with_capacity(rects.len() * 2);
for &(lo_a, hi_a, lo_b, hi_b) in rects {
xs.push(lo_a);
xs.push(hi_a);
ys.push(lo_b);
ys.push(hi_b);
}
xs.sort_unstable();
xs.dedup();
ys.sort_unstable();
ys.dedup();
let mut area: u64 = 0;
for xi in 0..xs.len().saturating_sub(1) {
let x0 = xs[xi];
let x1 = xs[xi + 1];
let dx = u64::from(x1 - x0);
for yi in 0..ys.len().saturating_sub(1) {
let y0 = ys[yi];
let y1 = ys[yi + 1];
let covered = rects.iter().any(|&(lo_a, hi_a, lo_b, hi_b)| {
lo_a <= x0 && x1 <= hi_a && lo_b <= y0 && y1 <= hi_b
});
if covered {
let dy = u64::from(y1 - y0);
area = area.saturating_add(dx.saturating_mul(dy));
}
}
}
area
}
fn project_extreme_points(
placement: &Placement3D,
placements: &[Placement3D],
bin: &Bin3D,
eps: &mut BTreeSet<(u32, u32, u32)>,
) -> usize {
let px = placement.x;
let py = placement.y;
let pz = placement.z;
let w = placement.width;
let h = placement.height;
let d = placement.depth;
let mut generated = 0;
let mut insert_if_in_bin = |eps: &mut BTreeSet<(u32, u32, u32)>, x: u32, y: u32, z: u32| {
if x < bin.width && y < bin.height && z < bin.depth && eps.insert((z, y, x)) {
generated += 1;
}
};
let corner_x = px.saturating_add(w);
if corner_x < bin.width {
let mut proj_y: u32 = 0;
let mut proj_z: u32 = 0;
for other in placements {
let o_x_end = other.x.saturating_add(other.width);
let o_y_end = other.y.saturating_add(other.height);
let o_z_end = other.z.saturating_add(other.depth);
if o_y_end <= py
&& other.x <= corner_x
&& corner_x < o_x_end
&& other.z <= pz
&& pz < o_z_end
&& o_y_end > proj_y
{
proj_y = o_y_end;
}
if o_z_end <= pz
&& other.x <= corner_x
&& corner_x < o_x_end
&& other.y <= py
&& py < o_y_end
&& o_z_end > proj_z
{
proj_z = o_z_end;
}
}
insert_if_in_bin(eps, corner_x, proj_y, proj_z);
}
let corner_y = py.saturating_add(h);
if corner_y < bin.height {
let mut proj_x: u32 = 0;
let mut proj_z: u32 = 0;
for other in placements {
let o_x_end = other.x.saturating_add(other.width);
let o_y_end = other.y.saturating_add(other.height);
let o_z_end = other.z.saturating_add(other.depth);
if o_x_end <= px
&& other.y <= corner_y
&& corner_y < o_y_end
&& other.z <= pz
&& pz < o_z_end
&& o_x_end > proj_x
{
proj_x = o_x_end;
}
if o_z_end <= pz
&& other.y <= corner_y
&& corner_y < o_y_end
&& other.x <= px
&& px < o_x_end
&& o_z_end > proj_z
{
proj_z = o_z_end;
}
}
insert_if_in_bin(eps, proj_x, corner_y, proj_z);
}
let corner_z = pz.saturating_add(d);
if corner_z < bin.depth {
let mut proj_x: u32 = 0;
let mut proj_y: u32 = 0;
for other in placements {
let o_x_end = other.x.saturating_add(other.width);
let o_y_end = other.y.saturating_add(other.height);
let o_z_end = other.z.saturating_add(other.depth);
if o_x_end <= px
&& other.y <= py
&& py < o_y_end
&& other.z <= corner_z
&& corner_z < o_z_end
&& o_x_end > proj_x
{
proj_x = o_x_end;
}
if o_y_end <= py
&& other.x <= px
&& px < o_x_end
&& other.z <= corner_z
&& corner_z < o_z_end
&& o_y_end > proj_y
{
proj_y = o_y_end;
}
}
insert_if_in_bin(eps, proj_x, proj_y, corner_z);
}
generated
}
#[cfg(test)]
mod tests {
use super::*;
use crate::three_d::model::{Bin3D, BoxDemand3D, RotationMask3D, ThreeDOptions, ThreeDProblem};
fn problem_one_box() -> 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 extreme_points_places_single_box() {
let solution =
solve_extreme_points(&problem_one_box(), &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
assert_eq!(solution.bin_count, 1);
assert_eq!(solution.algorithm, "extreme_points");
}
#[test]
fn extreme_points_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_extreme_points(&problem, &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
assert_eq!(solution.bin_count, 2);
}
#[test]
fn extreme_points_respects_rotation_mask() {
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_extreme_points(&problem, &ThreeDOptions::default()).expect("solve");
assert!(solution.unplaced.is_empty());
}
#[test]
fn each_scoring_variant_produces_a_valid_solution() {
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: 3,
height: 3,
depth: 3,
quantity: 5,
allowed_rotations: RotationMask3D::ALL,
}],
};
type Solver = fn(&ThreeDProblem, &ThreeDOptions) -> Result<ThreeDSolution>;
let cases: [(&str, Solver); 6] = [
("extreme_points", solve_extreme_points),
("extreme_points_residual_space", solve_extreme_points_residual_space),
("extreme_points_free_volume", solve_extreme_points_free_volume),
("extreme_points_bottom_left_back", solve_extreme_points_bottom_left_back),
("extreme_points_contact_point", solve_extreme_points_contact_point),
("extreme_points_euclidean", solve_extreme_points_euclidean),
];
for (name, solver) in cases {
let solution = solver(&problem, &ThreeDOptions::default()).expect("solve");
assert_eq!(solution.algorithm, name);
assert!(solution.unplaced.is_empty(), "{name}");
assert!(solution.bin_count >= 1, "{name}");
}
}
#[test]
fn extreme_points_honours_bin_quantity_cap() {
let problem = ThreeDProblem {
bins: vec![Bin3D {
name: "b".into(),
width: 5,
height: 5,
depth: 5,
cost: 1.0,
quantity: Some(1),
}],
demands: vec![BoxDemand3D {
name: "a".into(),
width: 5,
height: 5,
depth: 5,
quantity: 2,
allowed_rotations: RotationMask3D::XYZ,
}],
};
let solution = solve_extreme_points(&problem, &ThreeDOptions::default()).expect("solve");
assert_eq!(solution.bin_count, 1);
assert_eq!(solution.unplaced.len(), 1);
}
}