Skip to main content

bin_packing/three_d/
model.rs

1//! Data model types for 3D rectangular bin packing problems and solutions.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{BinPackingError, Result};
6
7/// Maximum supported per-axis dimension. Chosen so that
8/// `MAX_DIMENSION_3D^3 = 2^45` leaves 2^19 of headroom in `u64` for
9/// per-bin / per-multistart accumulation. Smaller than the 1D/2D
10/// `1 << 30` cap by design — the cube of `1 << 30` overflows `u64`.
11pub const MAX_DIMENSION_3D: u32 = 1 << 15;
12
13/// Maximum number of bins a 3D solution may consume. Solvers that would
14/// produce more bins than this should abort with `InvalidInput`.
15pub const MAX_BIN_COUNT_3D: usize = 1 << 15;
16
17/// Algorithm selector for [`solve_3d`](super::solve_3d).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "snake_case")]
20pub enum ThreeDAlgorithm {
21    /// Try multiple strategies and return the best.
22    #[default]
23    Auto,
24    /// Extreme Points construction with volume-fit residual scoring (default EP).
25    ExtremePoints,
26    /// Extreme Points construction with residual-space scoring (Crainic-Perboli-Tadei "RS").
27    ExtremePointsResidualSpace,
28    /// Extreme Points construction with free-volume scoring (Crainic-Perboli-Tadei "FV").
29    ExtremePointsFreeVolume,
30    /// Extreme Points construction with bottom-left-back tiebreaking.
31    ExtremePointsBottomLeftBack,
32    /// Extreme Points construction with contact-point scoring.
33    ExtremePointsContactPoint,
34    /// Extreme Points construction with Euclidean-distance scoring (Crainic-Perboli-Tadei "EU").
35    ExtremePointsEuclidean,
36    // Note: serde's `rename_all = "snake_case"` would convert `Guillotine3D`
37    // to `guillotine3_d`, which contradicts the wire format. Each
38    // digit-containing variant uses an explicit rename to match the spec.
39    /// Guillotine 3D beam search with best-volume-fit ranking.
40    #[serde(rename = "guillotine_3d")]
41    Guillotine3D,
42    /// Guillotine 3D beam search ranked by shortest leftover edge.
43    #[serde(rename = "guillotine_3d_best_short_side_fit")]
44    Guillotine3DBestShortSideFit,
45    /// Guillotine 3D beam search ranked by longest leftover edge.
46    #[serde(rename = "guillotine_3d_best_long_side_fit")]
47    Guillotine3DBestLongSideFit,
48    /// Guillotine 3D beam search splitting along the shortest leftover axis.
49    #[serde(rename = "guillotine_3d_shorter_leftover_axis")]
50    Guillotine3DShorterLeftoverAxis,
51    /// Guillotine 3D beam search splitting along the longest leftover axis.
52    #[serde(rename = "guillotine_3d_longer_leftover_axis")]
53    Guillotine3DLongerLeftoverAxis,
54    /// Guillotine 3D beam search minimising the new sub-cuboid volume on split.
55    #[serde(rename = "guillotine_3d_min_volume_split")]
56    Guillotine3DMinVolumeSplit,
57    /// Guillotine 3D beam search maximising the new sub-cuboid volume on split.
58    #[serde(rename = "guillotine_3d_max_volume_split")]
59    Guillotine3DMaxVolumeSplit,
60    /// Layer-building (horizontal layers) with `auto` 2D inner backend.
61    LayerBuilding,
62    /// Layer-building with the `max_rects` 2D inner backend.
63    LayerBuildingMaxRects,
64    /// Layer-building with the `skyline` 2D inner backend.
65    LayerBuildingSkyline,
66    /// Layer-building with the `guillotine` 2D inner backend.
67    LayerBuildingGuillotine,
68    /// Layer-building with the `best_fit_decreasing_height` shelf inner backend.
69    LayerBuildingShelf,
70    /// Bischoff & Marriott vertical wall-building.
71    WallBuilding,
72    /// Column / vertical-stack building with 2D footprint packing.
73    ColumnBuilding,
74    /// Deepest-Bottom-Left placement (Karabulut & İnceoğlu).
75    DeepestBottomLeft,
76    /// Deepest-Bottom-Left-Fill placement.
77    DeepestBottomLeftFill,
78    /// First-fit decreasing by volume.
79    FirstFitDecreasingVolume,
80    /// Best-fit decreasing by volume.
81    BestFitDecreasingVolume,
82    /// Multi-start randomized EP meta-strategy.
83    MultiStart,
84    /// GRASP construction + local search.
85    Grasp,
86    /// Standalone local search seeded from FFD.
87    LocalSearch,
88    /// Restricted Martello-Pisinger-Vigo branch-and-bound exact backend.
89    BranchAndBound,
90}
91
92/// A bin (container) entry that demands can be placed inside.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Bin3D {
95    /// Human-readable identifier for this bin type.
96    pub name: String,
97    /// Bin width (x axis).
98    pub width: u32,
99    /// Bin height (y axis, vertical).
100    pub height: u32,
101    /// Bin depth (z axis).
102    pub depth: u32,
103    /// Per-unit cost of consuming a bin of this type.
104    #[serde(default = "default_bin_cost")]
105    pub cost: f64,
106    /// Optional cap on the number of bins of this type that may be used.
107    #[serde(default)]
108    pub quantity: Option<usize>,
109}
110
111fn default_bin_cost() -> f64 {
112    1.0
113}
114
115fn default_rotation_mask() -> RotationMask3D {
116    RotationMask3D::ALL
117}
118
119/// A demand for a set of identical rectangular boxes.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct BoxDemand3D {
122    /// Human-readable identifier for the demand.
123    pub name: String,
124    /// Required width (declared x-extent).
125    pub width: u32,
126    /// Required height (declared y-extent).
127    pub height: u32,
128    /// Required depth (declared z-extent).
129    pub depth: u32,
130    /// Number of identical boxes required.
131    pub quantity: usize,
132    /// Bitmask of axis-permutation rotations the solver may apply to this box.
133    /// Defaults to [`RotationMask3D::ALL`].
134    #[serde(default = "default_rotation_mask")]
135    pub allowed_rotations: RotationMask3D,
136}
137
138/// One of the six axis-permutation rotations of a rectangular box.
139///
140/// Each variant denotes the permutation `(input_a, input_b, input_c)` where
141/// each letter selects which declared extent maps onto the bin's x, y, and z
142/// axis respectively. For example, [`Rotation3D::Zxy`] applied to a box
143/// declared as `(width=3, height=5, depth=7)` produces placement extents
144/// `(x_extent=7, y_extent=3, z_extent=5)`.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum Rotation3D {
148    /// Identity: `(w, h, d)` → `(w, h, d)`.
149    Xyz,
150    /// `(w, h, d)` → `(w, d, h)`.
151    Xzy,
152    /// `(w, h, d)` → `(h, w, d)`.
153    Yxz,
154    /// `(w, h, d)` → `(h, d, w)`.
155    Yzx,
156    /// `(w, h, d)` → `(d, w, h)`.
157    Zxy,
158    /// `(w, h, d)` → `(d, h, w)`.
159    Zyx,
160}
161
162impl Rotation3D {
163    /// Apply this rotation to a declared `(w, h, d)` box, returning the
164    /// `(x_extent, y_extent, z_extent)` of the placed box.
165    pub fn apply(self, width: u32, height: u32, depth: u32) -> (u32, u32, u32) {
166        match self {
167            Self::Xyz => (width, height, depth),
168            Self::Xzy => (width, depth, height),
169            Self::Yxz => (height, width, depth),
170            Self::Yzx => (height, depth, width),
171            Self::Zxy => (depth, width, height),
172            Self::Zyx => (depth, height, width),
173        }
174    }
175
176    /// Bit position of this rotation inside [`RotationMask3D`].
177    pub(crate) fn bit(self) -> u8 {
178        match self {
179            Self::Xyz => 0,
180            Self::Xzy => 1,
181            Self::Yxz => 2,
182            Self::Yzx => 3,
183            Self::Zxy => 4,
184            Self::Zyx => 5,
185        }
186    }
187}
188
189/// Bitmask over the six [`Rotation3D`] axis permutations.
190///
191/// A box has 24 orientation-preserving rotations in 3D, but only the six
192/// distinct *axis permutations* of `(w, h, d)` produce different placements
193/// (the other 18 collapse onto these because the box is unlabelled).
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
195pub struct RotationMask3D(u8);
196
197impl RotationMask3D {
198    /// Identity (`Rotation3D::Xyz`) only.
199    pub const XYZ: Self = Self(1 << 0);
200    /// `Rotation3D::Xzy` only.
201    pub const XZY: Self = Self(1 << 1);
202    /// `Rotation3D::Yxz` only.
203    pub const YXZ: Self = Self(1 << 2);
204    /// `Rotation3D::Yzx` only.
205    pub const YZX: Self = Self(1 << 3);
206    /// `Rotation3D::Zxy` only.
207    pub const ZXY: Self = Self(1 << 4);
208    /// `Rotation3D::Zyx` only.
209    pub const ZYX: Self = Self(1 << 5);
210    /// All six axis permutations.
211    pub const ALL: Self = Self(0b00111111);
212    /// Only the two permutations that preserve the y-axis as vertical
213    /// (`Xyz` and `Zyx`).
214    pub const UPRIGHT: Self = Self(0b00100001);
215    /// Empty mask. Rejected at validation time.
216    pub const NONE: Self = Self(0);
217
218    /// Whether this mask contains the given rotation.
219    pub fn contains(self, rotation: Rotation3D) -> bool {
220        (self.0 & (1 << rotation.bit())) != 0
221    }
222
223    /// Whether the mask is empty.
224    pub fn is_empty(self) -> bool {
225        self.0 == 0
226    }
227
228    /// Iterator over the rotations contained in this mask, in `Rotation3D`
229    /// declaration order (`Xyz`, `Xzy`, `Yxz`, `Yzx`, `Zxy`, `Zyx`).
230    pub fn iter(self) -> impl Iterator<Item = Rotation3D> {
231        const ALL: [Rotation3D; 6] = [
232            Rotation3D::Xyz,
233            Rotation3D::Xzy,
234            Rotation3D::Yxz,
235            Rotation3D::Yzx,
236            Rotation3D::Zxy,
237            Rotation3D::Zyx,
238        ];
239        ALL.into_iter().filter(move |rot| self.contains(*rot))
240    }
241}
242
243/// A single placed box on a packed bin.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct Placement3D {
246    /// Name of the originating demand.
247    pub name: String,
248    /// X offset of the box's near-bottom-left corner.
249    pub x: u32,
250    /// Y offset of the box's near-bottom-left corner.
251    pub y: u32,
252    /// Z offset of the box's near-bottom-left corner.
253    pub z: u32,
254    /// Width of the placed box after rotation.
255    pub width: u32,
256    /// Height of the placed box after rotation.
257    pub height: u32,
258    /// Depth of the placed box after rotation.
259    pub depth: u32,
260    /// Rotation applied relative to the demand's declared `(w, h, d)`.
261    pub rotation: Rotation3D,
262}
263
264/// A single packed bin layout produced by the solver.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct BinLayout3D {
267    /// Name of the bin type consumed by this layout.
268    pub bin_name: String,
269    /// Bin width.
270    pub width: u32,
271    /// Bin height.
272    pub height: u32,
273    /// Bin depth.
274    pub depth: u32,
275    /// Cost of consuming this bin.
276    pub cost: f64,
277    /// Boxes placed in this bin.
278    pub placements: Vec<Placement3D>,
279    /// Total volume occupied by the placements.
280    pub used_volume: u64,
281    /// Total wasted volume in this bin.
282    pub waste_volume: u64,
283}
284
285/// Per-bin procurement summary for a 3D solution.
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287pub struct BinRequirement3D {
288    /// Name of the bin type.
289    pub bin_name: String,
290    /// Bin width.
291    pub bin_width: u32,
292    /// Bin height.
293    pub bin_height: u32,
294    /// Bin depth.
295    pub bin_depth: u32,
296    /// Cost of consuming one bin of this type.
297    pub cost: f64,
298    /// Declared inventory available for this bin type, if capped.
299    pub available_quantity: Option<usize>,
300    /// Number of bins used in the returned solution.
301    pub used_quantity: usize,
302    /// Number of bins required to satisfy the full demand under the chosen mix.
303    pub required_quantity: usize,
304    /// Additional bins needed beyond `available_quantity`.
305    pub additional_quantity_needed: usize,
306}
307
308/// Metrics captured while running a 3D solver.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
310pub struct SolverMetrics3D {
311    /// Number of top-level solver iterations performed. The exact meaning
312    /// depends on the algorithm: deterministic constructive heuristics
313    /// (`extreme_points*`, `deepest_bottom_left*`, `wall_building`,
314    /// `column_building`, `layer_building*`,
315    /// `first_fit_decreasing_volume`, `best_fit_decreasing_volume`)
316    /// report 1; `multi_start` reports the number of restart loops it
317    /// executed; `local_search` reports the total number of improvement
318    /// passes; `grasp` reports the number of restart loops; `guillotine_3d*`
319    /// reports the number of beam-expansion steps; `branch_and_bound`
320    /// reports the number of outer search depths visited.
321    pub iterations: usize,
322    /// Number of states explored during search.
323    pub explored_states: usize,
324    /// Number of extreme points generated by EP-family solvers. Set to 0
325    /// by every other algorithm.
326    pub extreme_points_generated: usize,
327    /// Number of branch-and-bound nodes expanded by the exact backend.
328    /// Set to 0 by every other algorithm.
329    pub branch_and_bound_nodes: usize,
330    /// Free-form notes emitted by the solver for diagnostics.
331    pub notes: Vec<String>,
332}
333
334/// A complete solution returned by [`solve_3d`](super::solve_3d).
335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
336pub struct ThreeDSolution {
337    /// Name of the algorithm that produced this solution. When the user
338    /// passes `ThreeDAlgorithm::Auto`, this is the *leaf* algorithm name
339    /// (e.g. `"extreme_points_residual_space"`), never the literal string
340    /// `"auto"`. Mirrors the 1D / 2D convention.
341    pub algorithm: String,
342    /// Whether the solution is proven optimal. Set to `true` only by
343    /// `branch_and_bound` when the search exhausts the tree below
344    /// `branch_and_bound_node_limit`. Every other algorithm leaves this
345    /// as `false`.
346    pub exact: bool,
347    /// Optional lower bound on the optimal objective (number of bins).
348    /// Populated only by `branch_and_bound` from the L0/L1/L2 bounds it
349    /// computes; `None` for every other algorithm in v1.
350    pub lower_bound: Option<f64>,
351    /// Whether the layouts are guillotine-compatible. Set to `true` by
352    /// `guillotine_3d*` and by `layer_building_guillotine`; `false` by
353    /// every other algorithm.
354    pub guillotine: bool,
355    /// Number of bins consumed.
356    pub bin_count: usize,
357    /// Total wasted volume across all bins.
358    pub total_waste_volume: u64,
359    /// Total material cost across all bins.
360    pub total_cost: f64,
361    /// Per-bin layouts in descending order of utilization.
362    pub layouts: Vec<BinLayout3D>,
363    /// Per-bin requirement summary. Populated by `solve_3d` (not by the
364    /// individual algorithm) when at least one `Bin3D.quantity` cap is
365    /// set; otherwise an empty `Vec`. See "Inventory-aware re-solve" in
366    /// the spec for the relaxed-pass mechanic.
367    #[serde(default)]
368    pub bin_requirements: Vec<BinRequirement3D>,
369    /// Boxes the solver was unable to place. Each entry is a
370    /// `BoxDemand3D` with `quantity = 1` (one entry per unplaced
371    /// instance), matching how 2D returns `unplaced: Vec<RectDemand2D>`.
372    pub unplaced: Vec<BoxDemand3D>,
373    /// Metrics captured while solving.
374    pub metrics: SolverMetrics3D,
375}
376
377/// Input problem passed to [`solve_3d`](super::solve_3d).
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379pub struct ThreeDProblem {
380    /// Available bin types.
381    pub bins: Vec<Bin3D>,
382    /// Box demands to be placed in the bins.
383    pub demands: Vec<BoxDemand3D>,
384}
385
386/// Options controlling how [`solve_3d`](super::solve_3d) runs.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct ThreeDOptions {
389    /// Algorithm to dispatch to.
390    #[serde(default)]
391    pub algorithm: ThreeDAlgorithm,
392    /// Number of multistart restarts used by randomized strategies.
393    #[serde(default = "default_multistart_runs")]
394    pub multistart_runs: usize,
395    /// Number of improvement rounds per local search start.
396    #[serde(default = "default_improvement_rounds")]
397    pub improvement_rounds: usize,
398    /// Beam width for the guillotine beam search backend.
399    #[serde(default = "default_beam_width")]
400    pub beam_width: usize,
401    /// Maximum number of demand types for the Auto mode to attempt the exact backend.
402    #[serde(default = "default_auto_exact_max_types")]
403    pub auto_exact_max_types: usize,
404    /// Maximum total quantity for the Auto mode to attempt the exact backend.
405    #[serde(default = "default_auto_exact_max_quantity")]
406    pub auto_exact_max_quantity: usize,
407    /// Maximum number of branch-and-bound nodes the exact backend may expand.
408    #[serde(default = "default_branch_and_bound_node_limit")]
409    pub branch_and_bound_node_limit: usize,
410    /// Whether layouts must be guillotine-compatible.
411    #[serde(default)]
412    pub guillotine_required: bool,
413    /// Optional seed for reproducible randomized algorithms.
414    #[serde(default)]
415    pub seed: Option<u64>,
416}
417
418impl Default for ThreeDOptions {
419    fn default() -> Self {
420        Self {
421            algorithm: ThreeDAlgorithm::Auto,
422            multistart_runs: default_multistart_runs(),
423            improvement_rounds: default_improvement_rounds(),
424            beam_width: default_beam_width(),
425            auto_exact_max_types: default_auto_exact_max_types(),
426            auto_exact_max_quantity: default_auto_exact_max_quantity(),
427            branch_and_bound_node_limit: default_branch_and_bound_node_limit(),
428            guillotine_required: false,
429            seed: None,
430        }
431    }
432}
433
434impl ThreeDSolution {
435    /// Lexicographic ranking comparator. Mirrors `OneDSolution::is_better_than`
436    /// and `TwoDSolution::is_better_than`. The tuple is
437    /// `(unplaced.len(), bin_count, total_waste_volume, OrderedFloat(total_cost))`.
438    /// `guillotine_required` is **not** part of the comparator — that filter
439    /// is enforced by `auto.rs::solve_auto_guillotine` narrowing its
440    /// candidate set, exactly the same way 2D does it.
441    // Consumed by `three_d::auto` and per-algorithm solvers (Task 6+); kept
442    // live here via the tie-break regression test in this module.
443    #[allow(dead_code)]
444    pub(crate) fn is_better_than(&self, other: &Self) -> bool {
445        (
446            self.unplaced.len(),
447            self.bin_count,
448            self.total_waste_volume,
449            OrderedFloat3D(self.total_cost),
450        ) < (
451            other.unplaced.len(),
452            other.bin_count,
453            other.total_waste_volume,
454            OrderedFloat3D(other.total_cost),
455        )
456    }
457}
458
459// Used by `ThreeDSolution::is_better_than` (see above) which is exercised
460// by the tie-break regression test; the struct itself is only constructed
461// inside that comparator.
462#[allow(dead_code)]
463#[derive(Debug, Clone, Copy, PartialEq)]
464struct OrderedFloat3D(f64);
465impl Eq for OrderedFloat3D {}
466impl PartialOrd for OrderedFloat3D {
467    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
468        Some(self.cmp(other))
469    }
470}
471impl Ord for OrderedFloat3D {
472    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
473        self.0.total_cmp(&other.0)
474    }
475}
476
477fn default_multistart_runs() -> usize {
478    12
479}
480fn default_improvement_rounds() -> usize {
481    24
482}
483fn default_beam_width() -> usize {
484    8
485}
486fn default_auto_exact_max_types() -> usize {
487    8
488}
489fn default_auto_exact_max_quantity() -> usize {
490    32
491}
492fn default_branch_and_bound_node_limit() -> usize {
493    1_000_000
494}
495
496/// A pre-instanced item ready for placement (one entry per `quantity`).
497// Consumed by the Task 6+ EP / guillotine / layer-building solvers via
498// `ThreeDProblem::expanded_items`.
499#[allow(dead_code)]
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub(crate) struct ItemInstance3D {
502    pub(crate) demand_index: usize,
503    pub(crate) name: String,
504    pub(crate) width: u32,
505    pub(crate) height: u32,
506    pub(crate) depth: u32,
507    pub(crate) allowed_rotations: RotationMask3D,
508}
509
510impl ItemInstance3D {
511    /// Yields `(rotation, x_extent, y_extent, z_extent)` for every rotation
512    /// allowed by `allowed_rotations`, deduplicating rotations that produce
513    /// identical extents (e.g. a cube collapses all six rotations into one).
514    // Consumed by Task 6+ placement engines.
515    #[allow(dead_code)]
516    pub(crate) fn orientations(&self) -> impl Iterator<Item = (Rotation3D, u32, u32, u32)> + '_ {
517        let mut seen: Vec<(u32, u32, u32)> = Vec::with_capacity(6);
518        self.allowed_rotations.iter().filter_map(move |rotation| {
519            let extents = rotation.apply(self.width, self.height, self.depth);
520            if seen.contains(&extents) {
521                None
522            } else {
523                seen.push(extents);
524                Some((rotation, extents.0, extents.1, extents.2))
525            }
526        })
527    }
528}
529
530impl ThreeDProblem {
531    /// Boundary-validate the problem. Called by `solve_3d` before any algorithm runs.
532    pub(crate) fn validate(&self) -> Result<()> {
533        if self.bins.is_empty() {
534            return Err(BinPackingError::InvalidInput(
535                "at least one bin entry is required".to_string(),
536            ));
537        }
538        if self.demands.is_empty() {
539            return Err(BinPackingError::InvalidInput(
540                "at least one box demand entry is required".to_string(),
541            ));
542        }
543
544        for bin in &self.bins {
545            if bin.width == 0 || bin.height == 0 || bin.depth == 0 {
546                return Err(BinPackingError::InvalidInput(format!(
547                    "bin `{}` must have positive width, height, and depth",
548                    bin.name
549                )));
550            }
551            if bin.width > MAX_DIMENSION_3D
552                || bin.height > MAX_DIMENSION_3D
553                || bin.depth > MAX_DIMENSION_3D
554            {
555                return Err(BinPackingError::InvalidInput(format!(
556                    "bin `{}` dimensions exceed the supported maximum of {}",
557                    bin.name, MAX_DIMENSION_3D
558                )));
559            }
560            if !bin.cost.is_finite() || bin.cost < 0.0 {
561                return Err(BinPackingError::InvalidInput(format!(
562                    "bin `{}` must have a finite non-negative cost",
563                    bin.name
564                )));
565            }
566            if let Some(quantity) = bin.quantity
567                && quantity == 0
568            {
569                return Err(BinPackingError::InvalidInput(format!(
570                    "bin `{}` quantity, if set, must be positive",
571                    bin.name
572                )));
573            }
574        }
575
576        for demand in &self.demands {
577            if demand.width == 0 || demand.height == 0 || demand.depth == 0 {
578                return Err(BinPackingError::InvalidInput(format!(
579                    "demand `{}` must have positive width, height, and depth",
580                    demand.name
581                )));
582            }
583            if demand.width > MAX_DIMENSION_3D
584                || demand.height > MAX_DIMENSION_3D
585                || demand.depth > MAX_DIMENSION_3D
586            {
587                return Err(BinPackingError::InvalidInput(format!(
588                    "demand `{}` dimensions exceed the supported maximum of {}",
589                    demand.name, MAX_DIMENSION_3D
590                )));
591            }
592            if demand.quantity == 0 {
593                return Err(BinPackingError::InvalidInput(format!(
594                    "demand `{}` must have positive quantity",
595                    demand.name
596                )));
597            }
598            if demand.allowed_rotations.is_empty() {
599                return Err(BinPackingError::InvalidInput(format!(
600                    "demand `{}` must allow at least one rotation",
601                    demand.name
602                )));
603            }
604        }
605
606        Ok(())
607    }
608
609    /// Verify that every demand can fit *some* bin in *some* allowed rotation.
610    pub(crate) fn ensure_feasible_demands(&self) -> Result<()> {
611        for demand in &self.demands {
612            let feasible = self.bins.iter().any(|bin| {
613                demand.allowed_rotations.iter().any(|rotation| {
614                    let (x, y, z) = rotation.apply(demand.width, demand.height, demand.depth);
615                    bin.width >= x && bin.height >= y && bin.depth >= z
616                })
617            });
618            if !feasible {
619                return Err(BinPackingError::Infeasible3D {
620                    item: demand.name.clone(),
621                    width: demand.width,
622                    height: demand.height,
623                    depth: demand.depth,
624                });
625            }
626        }
627        Ok(())
628    }
629
630    /// Expand each demand into one [`ItemInstance3D`] per `quantity`.
631    // Consumed by Task 6+ placement engines.
632    #[allow(dead_code)]
633    pub(crate) fn expanded_items(&self) -> Vec<ItemInstance3D> {
634        let mut items = Vec::new();
635        for (index, demand) in self.demands.iter().enumerate() {
636            for _ in 0..demand.quantity {
637                items.push(ItemInstance3D {
638                    demand_index: index,
639                    name: demand.name.clone(),
640                    width: demand.width,
641                    height: demand.height,
642                    depth: demand.depth,
643                    allowed_rotations: demand.allowed_rotations,
644                });
645            }
646        }
647        items
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use serde_json::json;
655
656    #[test]
657    fn rotation3d_serializes_as_snake_case() {
658        let value = serde_json::to_value(Rotation3D::Zxy).expect("serialize");
659        assert_eq!(value, json!("zxy"));
660    }
661
662    #[test]
663    fn algorithm_serializes_with_explicit_renames_for_digit_variants() {
664        // Catches the regression where serde's snake_case converter would
665        // produce `guillotine3_d` instead of the spec's `guillotine_3d`.
666        let cases = [
667            (ThreeDAlgorithm::Auto, "auto"),
668            (ThreeDAlgorithm::ExtremePoints, "extreme_points"),
669            (ThreeDAlgorithm::Guillotine3D, "guillotine_3d"),
670            (ThreeDAlgorithm::Guillotine3DBestShortSideFit, "guillotine_3d_best_short_side_fit"),
671            (ThreeDAlgorithm::LayerBuildingMaxRects, "layer_building_max_rects"),
672            (ThreeDAlgorithm::DeepestBottomLeftFill, "deepest_bottom_left_fill"),
673            (ThreeDAlgorithm::BranchAndBound, "branch_and_bound"),
674        ];
675        for (variant, expected) in cases {
676            let value = serde_json::to_value(variant).expect("serialize");
677            assert_eq!(value, json!(expected), "{:?}", variant);
678            let parsed: ThreeDAlgorithm =
679                serde_json::from_value(json!(expected)).expect("deserialize");
680            assert_eq!(parsed, variant);
681        }
682    }
683
684    #[test]
685    fn rotation_mask_all_contains_every_rotation() {
686        let mask = RotationMask3D::ALL;
687        for rot in [
688            Rotation3D::Xyz,
689            Rotation3D::Xzy,
690            Rotation3D::Yxz,
691            Rotation3D::Yzx,
692            Rotation3D::Zxy,
693            Rotation3D::Zyx,
694        ] {
695            assert!(mask.contains(rot), "ALL should contain {:?}", rot);
696        }
697    }
698
699    #[test]
700    fn rotation_mask_upright_only_keeps_y_axis() {
701        let upright = RotationMask3D::UPRIGHT;
702        assert!(upright.contains(Rotation3D::Xyz));
703        assert!(upright.contains(Rotation3D::Zyx));
704        assert!(!upright.contains(Rotation3D::Xzy));
705        assert!(!upright.contains(Rotation3D::Yxz));
706        assert!(!upright.contains(Rotation3D::Yzx));
707        assert!(!upright.contains(Rotation3D::Zxy));
708    }
709
710    #[test]
711    fn rotation_mask_none_is_empty() {
712        let none = RotationMask3D::NONE;
713        for rot in [
714            Rotation3D::Xyz,
715            Rotation3D::Xzy,
716            Rotation3D::Yxz,
717            Rotation3D::Yzx,
718            Rotation3D::Zxy,
719            Rotation3D::Zyx,
720        ] {
721            assert!(!none.contains(rot));
722        }
723    }
724
725    #[test]
726    fn rotation3d_apply_zxy_maps_input_dims_correctly() {
727        // Per spec: Rotation3D::Zxy applied to (w=3, h=5, d=7) yields
728        // (x_extent=7, y_extent=3, z_extent=5).
729        let (x, y, z) = Rotation3D::Zxy.apply(3, 5, 7);
730        assert_eq!((x, y, z), (7, 3, 5));
731    }
732
733    #[test]
734    fn rotation3d_apply_xyz_is_identity() {
735        let (x, y, z) = Rotation3D::Xyz.apply(3, 5, 7);
736        assert_eq!((x, y, z), (3, 5, 7));
737    }
738
739    #[test]
740    fn rotation3d_apply_zyx_swaps_x_and_z() {
741        let (x, y, z) = Rotation3D::Zyx.apply(3, 5, 7);
742        assert_eq!((x, y, z), (7, 5, 3));
743    }
744
745    #[test]
746    fn validate_rejects_empty_bins() {
747        let problem = ThreeDProblem { bins: vec![], demands: vec![sample_demand("a", 1, 1, 1, 1)] };
748        let err = problem.validate().expect_err("should reject");
749        assert!(
750            matches!(&err, crate::BinPackingError::InvalidInput(msg) if msg.contains("bin")),
751            "unexpected error: {err:?}",
752        );
753    }
754
755    #[test]
756    fn validate_rejects_empty_demands() {
757        let problem = ThreeDProblem { bins: vec![sample_bin("b", 10, 10, 10)], demands: vec![] };
758        let err = problem.validate().expect_err("should reject");
759        assert!(matches!(err, crate::BinPackingError::InvalidInput(_)));
760    }
761
762    #[test]
763    fn validate_rejects_zero_dimension_bin() {
764        let problem = ThreeDProblem {
765            bins: vec![sample_bin("b", 0, 10, 10)],
766            demands: vec![sample_demand("a", 1, 1, 1, 1)],
767        };
768        assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
769    }
770
771    #[test]
772    fn validate_rejects_oversized_dimension() {
773        let oversize = MAX_DIMENSION_3D + 1;
774        let problem = ThreeDProblem {
775            bins: vec![sample_bin("b", oversize, 10, 10)],
776            demands: vec![sample_demand("a", 1, 1, 1, 1)],
777        };
778        assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
779    }
780
781    #[test]
782    fn validate_rejects_non_finite_cost() {
783        let mut bin = sample_bin("b", 10, 10, 10);
784        bin.cost = f64::NAN;
785        let problem =
786            ThreeDProblem { bins: vec![bin], demands: vec![sample_demand("a", 1, 1, 1, 1)] };
787        assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
788    }
789
790    #[test]
791    fn validate_rejects_negative_cost() {
792        let mut bin = sample_bin("b", 10, 10, 10);
793        bin.cost = -1.0;
794        let problem =
795            ThreeDProblem { bins: vec![bin], demands: vec![sample_demand("a", 1, 1, 1, 1)] };
796        assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
797    }
798
799    #[test]
800    fn validate_rejects_zero_quantity_demand() {
801        let problem = ThreeDProblem {
802            bins: vec![sample_bin("b", 10, 10, 10)],
803            demands: vec![sample_demand("a", 1, 1, 1, 0)],
804        };
805        assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
806    }
807
808    #[test]
809    fn validate_rejects_empty_rotation_mask() {
810        let mut demand = sample_demand("a", 1, 1, 1, 1);
811        demand.allowed_rotations = RotationMask3D::NONE;
812        let problem =
813            ThreeDProblem { bins: vec![sample_bin("b", 10, 10, 10)], demands: vec![demand] };
814        assert!(matches!(problem.validate(), Err(crate::BinPackingError::InvalidInput(_))));
815    }
816
817    #[test]
818    fn validate_accepts_well_formed_problem() {
819        let problem = ThreeDProblem {
820            bins: vec![sample_bin("b", 10, 10, 10)],
821            demands: vec![sample_demand("a", 2, 3, 4, 1)],
822        };
823        problem.validate().expect("should accept");
824    }
825
826    #[test]
827    fn ensure_feasible_demands_rejects_oversize_item() {
828        let problem = ThreeDProblem {
829            bins: vec![sample_bin("b", 5, 5, 5)],
830            demands: vec![sample_demand("a", 6, 6, 6, 1)],
831        };
832        problem.validate().expect("validate ok");
833        let err = problem.ensure_feasible_demands().expect_err("should reject");
834        assert!(
835            matches!(&err, crate::BinPackingError::Infeasible3D { item, .. } if item == "a"),
836            "unexpected error: {err:?}",
837        );
838    }
839
840    #[test]
841    fn ensure_feasible_demands_uses_rotation() {
842        // 6x1x1 in a 1x6x1 bin: only the (Yxz) rotation works.
843        let problem = ThreeDProblem {
844            bins: vec![sample_bin("b", 1, 6, 1)],
845            demands: vec![sample_demand("a", 6, 1, 1, 1)],
846        };
847        problem.validate().expect("validate ok");
848        problem.ensure_feasible_demands().expect("should accept via rotation");
849    }
850
851    #[test]
852    fn expanded_items_yields_one_per_quantity() {
853        let problem = ThreeDProblem {
854            bins: vec![sample_bin("b", 10, 10, 10)],
855            demands: vec![sample_demand("a", 2, 3, 4, 5), sample_demand("c", 1, 1, 1, 2)],
856        };
857        let items = problem.expanded_items();
858        assert_eq!(items.len(), 7);
859        assert_eq!(items.iter().filter(|item| item.name == "a").count(), 5);
860        assert_eq!(items.iter().filter(|item| item.name == "c").count(), 2);
861    }
862
863    /// `ThreeDSolution::is_better_than` ranks on the 4-tuple
864    /// `(unplaced.len(), bin_count, total_waste_volume, total_cost)`.
865    /// Verify each key dominates in turn — mirrors the 1D / 2D
866    /// `is_better_than_tie_breaks_on_each_key` regression tests.
867    #[test]
868    fn three_d_is_better_than_tie_breaks_on_each_key() {
869        let base = sample_solution(0, 1, 0, 1.0);
870
871        // Fewer unplaced wins (primary key).
872        let more_unplaced =
873            ThreeDSolution { unplaced: vec![sample_demand("u", 1, 1, 1, 1)], ..base.clone() };
874        assert!(base.is_better_than(&more_unplaced));
875        assert!(!more_unplaced.is_better_than(&base));
876
877        // Fewer bins wins when unplaced ties.
878        let more_bins = ThreeDSolution { bin_count: 2, ..base.clone() };
879        assert!(base.is_better_than(&more_bins));
880
881        // Less waste wins when unplaced and bin_count tie.
882        let more_waste = ThreeDSolution { total_waste_volume: 100, ..base.clone() };
883        assert!(base.is_better_than(&more_waste));
884
885        // Lower cost wins when every preceding key ties.
886        let more_cost = ThreeDSolution { total_cost: base.total_cost + 1.0, ..base.clone() };
887        assert!(base.is_better_than(&more_cost));
888
889        // Identical solutions are not "better than" each other.
890        assert!(!base.is_better_than(&base));
891    }
892
893    fn sample_solution(
894        unplaced_count: usize,
895        bin_count: usize,
896        waste: u64,
897        cost: f64,
898    ) -> ThreeDSolution {
899        ThreeDSolution {
900            algorithm: "test".to_string(),
901            exact: false,
902            lower_bound: None,
903            guillotine: false,
904            bin_count,
905            total_waste_volume: waste,
906            total_cost: cost,
907            layouts: Vec::new(),
908            bin_requirements: Vec::new(),
909            unplaced: (0..unplaced_count)
910                .map(|i| sample_demand(&format!("u{i}"), 1, 1, 1, 1))
911                .collect(),
912            metrics: SolverMetrics3D::default(),
913        }
914    }
915
916    fn sample_bin(name: &str, w: u32, h: u32, d: u32) -> Bin3D {
917        Bin3D { name: name.to_string(), width: w, height: h, depth: d, cost: 1.0, quantity: None }
918    }
919
920    fn sample_demand(name: &str, w: u32, h: u32, d: u32, qty: usize) -> BoxDemand3D {
921        BoxDemand3D {
922            name: name.to_string(),
923            width: w,
924            height: h,
925            depth: d,
926            quantity: qty,
927            allowed_rotations: RotationMask3D::ALL,
928        }
929    }
930}