Skip to main content

bin_packing/two_d/
model.rs

1//! Data model types for 2D rectangular bin packing problems and solutions.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{BinPackingError, Result};
6
7/// Algorithm selector for [`solve_2d`](super::solve_2d).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(rename_all = "snake_case")]
10pub enum TwoDAlgorithm {
11    /// Try multiple strategies and return the best.
12    #[default]
13    Auto,
14    /// Classic MaxRects best-area-fit construction.
15    MaxRects,
16    /// MaxRects with best-short-side-fit placement scoring.
17    MaxRectsBestShortSideFit,
18    /// MaxRects with best-long-side-fit placement scoring.
19    MaxRectsBestLongSideFit,
20    /// MaxRects with bottom-left placement scoring.
21    MaxRectsBottomLeft,
22    /// MaxRects with contact-point placement scoring.
23    MaxRectsContactPoint,
24    /// Skyline-based construction.
25    Skyline,
26    /// Skyline construction ranked by minimum waste.
27    SkylineMinWaste,
28    /// Guillotine beam search.
29    Guillotine,
30    /// Guillotine beam search with best-short-side-fit candidate ranking.
31    GuillotineBestShortSideFit,
32    /// Guillotine beam search with best-long-side-fit candidate ranking.
33    GuillotineBestLongSideFit,
34    /// Guillotine beam search with shorter-leftover-axis split selection.
35    GuillotineShorterLeftoverAxis,
36    /// Guillotine beam search with longer-leftover-axis split selection.
37    GuillotineLongerLeftoverAxis,
38    /// Guillotine beam search with minimum-area split selection.
39    GuillotineMinAreaSplit,
40    /// Guillotine beam search with maximum-area split selection.
41    GuillotineMaxAreaSplit,
42    /// Next-fit decreasing height shelf heuristic.
43    NextFitDecreasingHeight,
44    /// First-fit decreasing height shelf heuristic.
45    FirstFitDecreasingHeight,
46    /// Best-fit decreasing height shelf heuristic.
47    BestFitDecreasingHeight,
48    /// Multistart MaxRects meta-strategy.
49    MultiStart,
50    /// Exhaustive rotation search: enumerates all 2^k rotation assignments
51    /// for k rotatable demand types (or samples `multistart_runs` random
52    /// assignments when k exceeds `auto_rotation_search_max_types`). Uses
53    /// MaxRects best-area-fit as the inner packer.
54    RotationSearch,
55}
56
57/// A sheet stock entry that demands can be placed on.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct Sheet2D {
60    /// Human-readable identifier for this sheet type.
61    pub name: String,
62    /// Sheet width.
63    pub width: u32,
64    /// Sheet height.
65    pub height: u32,
66    /// Per-unit cost of consuming a sheet of this type.
67    #[serde(default = "default_sheet_cost")]
68    pub cost: f64,
69    /// Optional cap on the number of sheets of this type that may be used.
70    #[serde(default)]
71    pub quantity: Option<usize>,
72    /// Material removed by the cutting tool on each cut of this sheet
73    /// (e.g., table-saw blade thickness, CNC router bit diameter). Kerf is
74    /// enforced as a minimum gap between edge-adjacent placements on the
75    /// same sheet and is applied symmetrically across all 2D algorithms.
76    /// Defaults to `0`, preserving pre-kerf-aware solver behavior.
77    #[serde(default)]
78    pub kerf: u32,
79    /// When `true`, the trailing placement on this sheet may extend up to
80    /// one `kerf` past the sheet's right and bottom boundaries. This models
81    /// a cut that runs off the stock — the blade exits the material with
82    /// only part of the kerf consuming material, and the rest is air. Does
83    /// not relax individual part feasibility: every part must still satisfy
84    /// `width <= sheet.width && height <= sheet.height`. Defaults to `false`
85    /// (pre-edge-relief behavior).
86    #[serde(default)]
87    pub edge_kerf_relief: bool,
88}
89
90/// Returns `(effective_width, effective_height)` for `sheet`, accounting
91/// for edge kerf relief. When `sheet.edge_kerf_relief` is `true`, both
92/// dimensions are padded by `sheet.kerf` (saturating), allowing a trailing
93/// placement to extend up to one kerf past the sheet boundary. When `false`,
94/// returns the sheet's declared dimensions unchanged.
95pub(crate) fn effective_bounds(sheet: &Sheet2D) -> (u32, u32) {
96    if sheet.edge_kerf_relief {
97        (sheet.width.saturating_add(sheet.kerf), sheet.height.saturating_add(sheet.kerf))
98    } else {
99        (sheet.width, sheet.height)
100    }
101}
102
103/// A demand for a set of identical rectangular pieces.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RectDemand2D {
106    /// Human-readable identifier for the demand.
107    pub name: String,
108    /// Required width of each rectangle.
109    pub width: u32,
110    /// Required height of each rectangle.
111    pub height: u32,
112    /// Number of identical rectangles required.
113    pub quantity: usize,
114    /// Whether the solver may rotate this rectangle 90 degrees.
115    #[serde(default = "default_can_rotate")]
116    pub can_rotate: bool,
117}
118
119/// A single rectangle placed on a packed sheet.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Placement2D {
122    /// Name of the originating demand.
123    pub name: String,
124    /// X offset of the rectangle's top-left corner on the sheet.
125    pub x: u32,
126    /// Y offset of the rectangle's top-left corner on the sheet.
127    pub y: u32,
128    /// Width of the placed rectangle after any rotation.
129    pub width: u32,
130    /// Height of the placed rectangle after any rotation.
131    pub height: u32,
132    /// Whether the rectangle was rotated 90 degrees from its declared orientation.
133    pub rotated: bool,
134}
135
136/// A single packed sheet layout produced by the solver.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct SheetLayout2D {
139    /// Name of the sheet type consumed by this layout.
140    pub sheet_name: String,
141    /// Sheet width.
142    pub width: u32,
143    /// Sheet height.
144    pub height: u32,
145    /// Cost of consuming this sheet.
146    pub cost: f64,
147    /// Rectangles placed on this sheet.
148    pub placements: Vec<Placement2D>,
149    /// Total area occupied by the placements.
150    pub used_area: u64,
151    /// Total wasted area on this sheet.
152    pub waste_area: u64,
153    /// Area lost to kerf on this sheet. Included in `waste_area`; reported
154    /// separately so callers can distinguish kerf loss from unused area.
155    pub kerf_area: u64,
156    /// Area of the single largest axis-aligned rectangle of unused space on
157    /// this sheet whose width and height both satisfy
158    /// `>= options.min_usable_side`. Zero if no such rectangle exists.
159    pub largest_usable_drop_area: u64,
160    /// Sum of `area²` over a canonical disjoint partition of this sheet's
161    /// free region, restricted to rectangles passing `min_usable_side`.
162    /// Rewards consolidation: for positive `a, b`, `a² + b² < (a+b)²`, so
163    /// merging two adjacent drops into one strictly increases the sum.
164    pub sum_sq_usable_drop_areas: u128,
165}
166
167/// Metrics captured while running a 2D solver.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SolverMetrics2D {
170    /// Number of top-level solver iterations performed.
171    pub iterations: usize,
172    /// Number of states explored during search.
173    pub explored_states: usize,
174    /// Free-form notes emitted by the solver for diagnostics.
175    pub notes: Vec<String>,
176}
177
178/// A complete solution returned by [`solve_2d`](super::solve_2d).
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct TwoDSolution {
181    /// Name of the algorithm that produced this solution.
182    pub algorithm: String,
183    /// Whether the layouts are guillotine-compatible.
184    pub guillotine: bool,
185    /// Number of sheets consumed.
186    pub sheet_count: usize,
187    /// Total wasted area across all sheets.
188    pub total_waste_area: u64,
189    /// Total area lost to kerf across all layouts.
190    pub total_kerf_area: u64,
191    /// Total material cost across all sheets.
192    pub total_cost: f64,
193    /// Maximum of `SheetLayout2D.largest_usable_drop_area` across all layouts.
194    /// Answers "what is the single biggest usable offcut this job yields?"
195    pub max_usable_drop_area: u64,
196    /// Sum (saturating) of `SheetLayout2D.sum_sq_usable_drop_areas` across
197    /// all layouts. Summation is meaningful because the sum-of-squares is
198    /// already additive over each sheet's disjoint free-region partition.
199    pub total_sum_sq_usable_drop_areas: u128,
200    /// Per-sheet layouts in descending order of utilization.
201    pub layouts: Vec<SheetLayout2D>,
202    /// Rectangles the solver was unable to place.
203    pub unplaced: Vec<RectDemand2D>,
204    /// Metrics captured while solving.
205    pub metrics: SolverMetrics2D,
206}
207
208/// Input problem passed to [`solve_2d`](super::solve_2d).
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct TwoDProblem {
211    /// Available sheet types.
212    pub sheets: Vec<Sheet2D>,
213    /// Rectangular demands to be placed on the sheets.
214    pub demands: Vec<RectDemand2D>,
215}
216
217/// Options controlling how [`solve_2d`](super::solve_2d) runs.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct TwoDOptions {
220    /// Algorithm to dispatch to.
221    #[serde(default)]
222    pub algorithm: TwoDAlgorithm,
223    /// Number of multistart restarts used by randomized strategies.
224    #[serde(default = "default_multistart_runs")]
225    pub multistart_runs: usize,
226    /// Beam width for the guillotine beam search backend.
227    #[serde(default = "default_beam_width")]
228    pub beam_width: usize,
229    /// Whether layouts must be guillotine-compatible.
230    #[serde(default)]
231    pub guillotine_required: bool,
232    /// Optional seed for reproducible randomized algorithms.
233    #[serde(default)]
234    pub seed: Option<u64>,
235    /// Minimum side length (both width and height) that a free-space rectangle
236    /// must satisfy to be counted as a "usable drop." Rectangles with either
237    /// side smaller than this threshold are treated as scrap and contribute
238    /// zero to the consolidation metrics. Default `0` admits every drop.
239    ///
240    /// Affects the tiebreakers `max_usable_drop_area` and
241    /// `total_sum_sq_usable_drop_areas` on `TwoDSolution`. Does not change
242    /// the primary ranking objectives (unplaced, sheet_count, waste_area,
243    /// cost).
244    #[serde(default)]
245    pub min_usable_side: u32,
246    /// Maximum number of rotatable demand types for which rotation search
247    /// uses exhaustive enumeration. When the number of rotatable types
248    /// exceeds this threshold, rotation search switches to sampling
249    /// `multistart_runs` random assignments instead. Also controls whether
250    /// Auto mode includes rotation search as a candidate.
251    #[serde(default = "default_auto_rotation_search_max_types")]
252    pub auto_rotation_search_max_types: usize,
253}
254
255impl Default for TwoDOptions {
256    fn default() -> Self {
257        Self {
258            algorithm: TwoDAlgorithm::Auto,
259            multistart_runs: default_multistart_runs(),
260            beam_width: default_beam_width(),
261            guillotine_required: false,
262            seed: None,
263            min_usable_side: 0,
264            auto_rotation_search_max_types: default_auto_rotation_search_max_types(),
265        }
266    }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub(crate) struct ItemInstance2D {
271    pub(crate) name: String,
272    pub(crate) width: u32,
273    pub(crate) height: u32,
274    pub(crate) can_rotate: bool,
275}
276
277impl ItemInstance2D {
278    pub(crate) fn orientations(&self) -> impl Iterator<Item = (u32, u32, bool)> + '_ {
279        let primary = std::iter::once((self.width, self.height, false));
280        let rotated = self
281            .can_rotate
282            .then_some((self.height, self.width, true))
283            .filter(|(width, height, _)| *width != self.width || *height != self.height)
284            .into_iter();
285        primary.chain(rotated)
286    }
287}
288
289const MAX_DIMENSION: u32 = 1 << 30;
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub(crate) struct Rect {
293    pub(crate) x: u32,
294    pub(crate) y: u32,
295    pub(crate) width: u32,
296    pub(crate) height: u32,
297}
298
299impl TwoDProblem {
300    pub(crate) fn validate(&self) -> Result<()> {
301        if self.sheets.is_empty() {
302            return Err(BinPackingError::InvalidInput(
303                "at least one sheet stock entry is required".to_string(),
304            ));
305        }
306
307        if self.demands.is_empty() {
308            return Err(BinPackingError::InvalidInput(
309                "at least one rectangular demand entry is required".to_string(),
310            ));
311        }
312
313        for sheet in &self.sheets {
314            if sheet.width == 0 || sheet.height == 0 {
315                return Err(BinPackingError::InvalidInput(format!(
316                    "sheet `{}` must have positive width and height",
317                    sheet.name
318                )));
319            }
320
321            if sheet.width > MAX_DIMENSION || sheet.height > MAX_DIMENSION {
322                return Err(BinPackingError::InvalidInput(format!(
323                    "sheet `{}` dimensions exceed the supported maximum of {}",
324                    sheet.name, MAX_DIMENSION
325                )));
326            }
327
328            if !sheet.cost.is_finite() || sheet.cost < 0.0 {
329                return Err(BinPackingError::InvalidInput(format!(
330                    "sheet `{}` must have a finite non-negative cost",
331                    sheet.name
332                )));
333            }
334
335            let shortest_side = sheet.width.min(sheet.height);
336            if u64::from(sheet.kerf) * 2 >= u64::from(shortest_side) {
337                return Err(BinPackingError::InvalidInput(format!(
338                    "sheet `{}` kerf {} is too large for shortest side {}",
339                    sheet.name, sheet.kerf, shortest_side
340                )));
341            }
342        }
343
344        for demand in &self.demands {
345            if demand.width == 0 || demand.height == 0 {
346                return Err(BinPackingError::InvalidInput(format!(
347                    "demand `{}` must have positive width and height",
348                    demand.name
349                )));
350            }
351
352            if demand.width > MAX_DIMENSION || demand.height > MAX_DIMENSION {
353                return Err(BinPackingError::InvalidInput(format!(
354                    "demand `{}` dimensions exceed the supported maximum of {}",
355                    demand.name, MAX_DIMENSION
356                )));
357            }
358
359            if demand.quantity == 0 {
360                return Err(BinPackingError::InvalidInput(format!(
361                    "demand `{}` must have positive quantity",
362                    demand.name
363                )));
364            }
365        }
366
367        Ok(())
368    }
369
370    pub(crate) fn ensure_feasible_demands(&self) -> Result<()> {
371        for demand in &self.demands {
372            let feasible = self.sheets.iter().any(|sheet| {
373                (sheet.width >= demand.width && sheet.height >= demand.height)
374                    || (demand.can_rotate
375                        && sheet.width >= demand.height
376                        && sheet.height >= demand.width)
377            });
378
379            if !feasible {
380                return Err(BinPackingError::Infeasible2D {
381                    item: demand.name.clone(),
382                    width: demand.width,
383                    height: demand.height,
384                });
385            }
386        }
387
388        Ok(())
389    }
390
391    pub(crate) fn expanded_items(&self) -> Vec<ItemInstance2D> {
392        let mut items = Vec::new();
393        for demand in &self.demands {
394            for _ in 0..demand.quantity {
395                items.push(ItemInstance2D {
396                    name: demand.name.clone(),
397                    width: demand.width,
398                    height: demand.height,
399                    can_rotate: demand.can_rotate,
400                });
401            }
402        }
403
404        items
405    }
406}
407
408impl Rect {
409    pub(crate) fn area(self) -> u64 {
410        u64::from(self.width) * u64::from(self.height)
411    }
412
413    pub(crate) fn fits(self, width: u32, height: u32) -> bool {
414        width <= self.width && height <= self.height
415    }
416
417    pub(crate) fn intersects(self, other: Self) -> bool {
418        let self_right = self.x.saturating_add(self.width);
419        let self_bottom = self.y.saturating_add(self.height);
420        let other_right = other.x.saturating_add(other.width);
421        let other_bottom = other.y.saturating_add(other.height);
422
423        self.x < other_right
424            && self_right > other.x
425            && self.y < other_bottom
426            && self_bottom > other.y
427    }
428
429    pub(crate) fn contains(self, other: Self) -> bool {
430        self.x <= other.x
431            && self.y <= other.y
432            && self.x.saturating_add(self.width) >= other.x.saturating_add(other.width)
433            && self.y.saturating_add(self.height) >= other.y.saturating_add(other.height)
434    }
435}
436
437impl TwoDSolution {
438    pub(crate) fn from_layouts(
439        algorithm: impl Into<String>,
440        guillotine: bool,
441        sheets: &[Sheet2D],
442        layouts: Vec<(usize, Vec<Placement2D>)>,
443        unplaced_items: Vec<ItemInstance2D>,
444        metrics: SolverMetrics2D,
445        min_usable_side: u32,
446    ) -> Self {
447        let mut computed_layouts = layouts
448            .into_iter()
449            .map(|(sheet_index, placements)| {
450                let sheet = &sheets[sheet_index];
451                let sheet_area = u64::from(sheet.width) * u64::from(sheet.height);
452                let used_area = placements
453                    .iter()
454                    .map(|placement| {
455                        let on_sheet_w = placement
456                            .x
457                            .saturating_add(placement.width)
458                            .min(sheet.width)
459                            .saturating_sub(placement.x);
460                        let on_sheet_h = placement
461                            .y
462                            .saturating_add(placement.height)
463                            .min(sheet.height)
464                            .saturating_sub(placement.y);
465                        u64::from(on_sheet_w) * u64::from(on_sheet_h)
466                    })
467                    .sum::<u64>();
468                // In debug builds, fail loudly if a solver produced more used
469                // area than the sheet provides — that indicates overlapping or
470                // off-sheet placements upstream. Release builds fall back to
471                // saturating subtraction so the bug surfaces as "0 waste"
472                // instead of a u64 underflow panic.
473                debug_assert!(
474                    used_area <= sheet_area,
475                    "sheet `{}` placements use {used_area} area but sheet capacity is {sheet_area}",
476                    sheet.name,
477                );
478                let waste_area = sheet_area.saturating_sub(used_area);
479
480                let kerf_area = super::kerf::kerf_area_for_layout(sheet, &placements);
481                let (largest_usable_drop_area, sum_sq_usable_drop_areas) =
482                    super::drops::usable_drop_metrics(sheet, &placements, min_usable_side);
483                SheetLayout2D {
484                    sheet_name: sheet.name.clone(),
485                    width: sheet.width,
486                    height: sheet.height,
487                    cost: sheet.cost,
488                    placements,
489                    used_area,
490                    waste_area,
491                    kerf_area,
492                    largest_usable_drop_area,
493                    sum_sq_usable_drop_areas,
494                }
495            })
496            .collect::<Vec<_>>();
497
498        computed_layouts.sort_by(|left, right| {
499            right
500                .used_area
501                .cmp(&left.used_area)
502                .then_with(|| left.sheet_name.cmp(&right.sheet_name))
503        });
504
505        let total_waste_area = computed_layouts.iter().map(|layout| layout.waste_area).sum();
506        let total_kerf_area = computed_layouts.iter().map(|layout| layout.kerf_area).sum();
507        let total_cost = computed_layouts.iter().map(|layout| layout.cost).sum();
508        let max_usable_drop_area = computed_layouts
509            .iter()
510            .map(|layout| layout.largest_usable_drop_area)
511            .max()
512            .unwrap_or(0);
513        let total_sum_sq_usable_drop_areas = computed_layouts
514            .iter()
515            .map(|layout| layout.sum_sq_usable_drop_areas)
516            .fold(0_u128, u128::saturating_add);
517        let mut unplaced = unplaced_items
518            .into_iter()
519            .map(|item| RectDemand2D {
520                name: item.name,
521                width: item.width,
522                height: item.height,
523                quantity: 1,
524                can_rotate: item.can_rotate,
525            })
526            .collect::<Vec<_>>();
527        unplaced.sort_by(|left, right| {
528            let left_area = u64::from(left.width) * u64::from(left.height);
529            let right_area = u64::from(right.width) * u64::from(right.height);
530            right_area.cmp(&left_area)
531        });
532
533        Self {
534            algorithm: algorithm.into(),
535            guillotine,
536            sheet_count: computed_layouts.len(),
537            total_waste_area,
538            total_kerf_area,
539            total_cost,
540            max_usable_drop_area,
541            total_sum_sq_usable_drop_areas,
542            layouts: computed_layouts,
543            unplaced,
544            metrics,
545        }
546    }
547
548    pub(crate) fn is_better_than(&self, other: &Self) -> bool {
549        // Lexicographic ranking key:
550        //   (unplaced_count, sheet_count, total_waste_area, total_cost,
551        //    -max_usable_drop_area, -total_sum_sq_usable_drop_areas)
552        //
553        // Consolidation is a tiebreaker AFTER the primary objectives
554        // (unplaced, sheet_count, waste_area, cost): a layout with even
555        // 1 sq unit less waste always beats a layout with a bigger drop.
556        // The consolidation terms are negated via `Reverse` so that *more*
557        // drop area / *higher* sum-of-squares sorts earlier (is "better")
558        // among candidates that tie on all four primary keys.
559        use std::cmp::Reverse;
560        (
561            self.unplaced.len(),
562            self.sheet_count,
563            self.total_waste_area,
564            OrderedFloat(self.total_cost),
565            Reverse(self.max_usable_drop_area),
566            Reverse(self.total_sum_sq_usable_drop_areas),
567        ) < (
568            other.unplaced.len(),
569            other.sheet_count,
570            other.total_waste_area,
571            OrderedFloat(other.total_cost),
572            Reverse(other.max_usable_drop_area),
573            Reverse(other.total_sum_sq_usable_drop_areas),
574        )
575    }
576}
577
578#[derive(Debug, Clone, Copy, PartialEq)]
579struct OrderedFloat(pub f64);
580
581impl Eq for OrderedFloat {}
582
583impl PartialOrd for OrderedFloat {
584    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
585        Some(self.cmp(other))
586    }
587}
588
589impl Ord for OrderedFloat {
590    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
591        self.0.total_cmp(&other.0)
592    }
593}
594
595fn default_sheet_cost() -> f64 {
596    1.0
597}
598
599fn default_can_rotate() -> bool {
600    true
601}
602
603fn default_multistart_runs() -> usize {
604    12
605}
606
607fn default_beam_width() -> usize {
608    8
609}
610
611fn default_auto_rotation_search_max_types() -> usize {
612    16
613}
614
615#[cfg(test)]
616mod tests {
617    use serde_json::json;
618
619    use super::*;
620
621    fn sample_problem() -> TwoDProblem {
622        TwoDProblem {
623            sheets: vec![Sheet2D {
624                name: "sheet".to_string(),
625                width: 10,
626                height: 8,
627                cost: 1.0,
628                quantity: None,
629                kerf: 0,
630                edge_kerf_relief: false,
631            }],
632            demands: vec![
633                RectDemand2D {
634                    name: "panel".to_string(),
635                    width: 4,
636                    height: 3,
637                    quantity: 2,
638                    can_rotate: true,
639                },
640                RectDemand2D {
641                    name: "brace".to_string(),
642                    width: 2,
643                    height: 2,
644                    quantity: 1,
645                    can_rotate: false,
646                },
647            ],
648        }
649    }
650
651    #[test]
652    fn serde_defaults_fill_in_optional_sheet_and_option_fields() {
653        let sheet: Sheet2D =
654            serde_json::from_value(json!({ "name": "sheet", "width": 12, "height": 8 }))
655                .expect("sheet");
656        assert_eq!(sheet.cost, 1.0);
657        assert_eq!(sheet.quantity, None);
658
659        let demand: RectDemand2D = serde_json::from_value(
660            json!({ "name": "panel", "width": 5, "height": 4, "quantity": 1 }),
661        )
662        .expect("demand");
663        assert!(demand.can_rotate);
664
665        let options: TwoDOptions = serde_json::from_value(json!({})).expect("options");
666        assert_eq!(options, TwoDOptions::default());
667    }
668
669    #[test]
670    fn validation_rejects_missing_or_invalid_two_d_inputs() {
671        let missing_sheets = TwoDProblem { sheets: Vec::new(), demands: sample_problem().demands };
672        assert!(matches!(
673            missing_sheets.validate(),
674            Err(BinPackingError::InvalidInput(message))
675                if message == "at least one sheet stock entry is required"
676        ));
677
678        let missing_demands = TwoDProblem { sheets: sample_problem().sheets, demands: Vec::new() };
679        assert!(matches!(
680            missing_demands.validate(),
681            Err(BinPackingError::InvalidInput(message))
682                if message == "at least one rectangular demand entry is required"
683        ));
684
685        let zero_sheet = TwoDProblem {
686            sheets: vec![Sheet2D {
687                name: "sheet".to_string(),
688                width: 0,
689                height: 8,
690                cost: 1.0,
691                quantity: None,
692                kerf: 0,
693                edge_kerf_relief: false,
694            }],
695            demands: vec![RectDemand2D {
696                name: "panel".to_string(),
697                width: 1,
698                height: 1,
699                quantity: 1,
700                can_rotate: false,
701            }],
702        };
703        assert!(matches!(
704            zero_sheet.validate(),
705            Err(BinPackingError::InvalidInput(message))
706                if message == "sheet `sheet` must have positive width and height"
707        ));
708
709        let zero_demand = TwoDProblem {
710            sheets: vec![Sheet2D {
711                name: "sheet".to_string(),
712                width: 8,
713                height: 8,
714                cost: 1.0,
715                quantity: None,
716                kerf: 0,
717                edge_kerf_relief: false,
718            }],
719            demands: vec![RectDemand2D {
720                name: "panel".to_string(),
721                width: 0,
722                height: 2,
723                quantity: 1,
724                can_rotate: false,
725            }],
726        };
727        assert!(matches!(
728            zero_demand.validate(),
729            Err(BinPackingError::InvalidInput(message))
730                if message == "demand `panel` must have positive width and height"
731        ));
732
733        let zero_quantity = TwoDProblem {
734            sheets: vec![Sheet2D {
735                name: "sheet".to_string(),
736                width: 8,
737                height: 8,
738                cost: 1.0,
739                quantity: None,
740                kerf: 0,
741                edge_kerf_relief: false,
742            }],
743            demands: vec![RectDemand2D {
744                name: "panel".to_string(),
745                width: 2,
746                height: 2,
747                quantity: 0,
748                can_rotate: false,
749            }],
750        };
751        assert!(matches!(
752            zero_quantity.validate(),
753            Err(BinPackingError::InvalidInput(message))
754                if message == "demand `panel` must have positive quantity"
755        ));
756    }
757
758    #[test]
759    fn feasibility_expansion_and_geometry_helpers_cover_rotation_paths() {
760        let feasible = sample_problem();
761        feasible.validate().expect("sample input should validate");
762        feasible.ensure_feasible_demands().expect("sample input should be feasible");
763
764        let rotated_only = TwoDProblem {
765            sheets: vec![Sheet2D {
766                name: "sheet".to_string(),
767                width: 4,
768                height: 6,
769                cost: 1.0,
770                quantity: None,
771                kerf: 0,
772                edge_kerf_relief: false,
773            }],
774            demands: vec![RectDemand2D {
775                name: "rotated".to_string(),
776                width: 6,
777                height: 4,
778                quantity: 1,
779                can_rotate: true,
780            }],
781        };
782        rotated_only.ensure_feasible_demands().expect("rotation should make item feasible");
783
784        let infeasible = TwoDProblem {
785            sheets: vec![Sheet2D {
786                name: "sheet".to_string(),
787                width: 4,
788                height: 6,
789                cost: 1.0,
790                quantity: None,
791                kerf: 0,
792                edge_kerf_relief: false,
793            }],
794            demands: vec![RectDemand2D {
795                name: "oversized".to_string(),
796                width: 7,
797                height: 4,
798                quantity: 1,
799                can_rotate: false,
800            }],
801        };
802        assert!(matches!(
803            infeasible.ensure_feasible_demands(),
804            Err(BinPackingError::Infeasible2D { item, width, height })
805                if item == "oversized" && width == 7 && height == 4
806        ));
807
808        let items = feasible.expanded_items();
809        assert_eq!(items.len(), 3);
810        assert_eq!(items[0].name, "panel");
811        assert_eq!(items[2].name, "brace");
812
813        let outer = Rect { x: 0, y: 0, width: 10, height: 8 };
814        let inner = Rect { x: 2, y: 2, width: 3, height: 4 };
815        let disjoint = Rect { x: 10, y: 0, width: 2, height: 2 };
816        assert_eq!(outer.area(), 80);
817        assert!(outer.fits(5, 4));
818        assert!(outer.contains(inner));
819        assert!(outer.intersects(inner));
820        assert!(!outer.intersects(disjoint));
821    }
822
823    #[test]
824    fn from_layouts_sorts_outputs_and_better_than_prefers_fewer_sheets() {
825        let sheets = vec![
826            Sheet2D {
827                name: "alpha".to_string(),
828                width: 10,
829                height: 10,
830                cost: 2.0,
831                quantity: None,
832                kerf: 0,
833                edge_kerf_relief: false,
834            },
835            Sheet2D {
836                name: "beta".to_string(),
837                width: 8,
838                height: 8,
839                cost: 1.0,
840                quantity: None,
841                kerf: 0,
842                edge_kerf_relief: false,
843            },
844        ];
845
846        let solution = TwoDSolution::from_layouts(
847            "maxrects",
848            false,
849            &sheets,
850            vec![
851                (
852                    1,
853                    vec![Placement2D {
854                        name: "small".to_string(),
855                        x: 0,
856                        y: 0,
857                        width: 4,
858                        height: 4,
859                        rotated: false,
860                    }],
861                ),
862                (
863                    0,
864                    vec![
865                        Placement2D {
866                            name: "large".to_string(),
867                            x: 0,
868                            y: 0,
869                            width: 5,
870                            height: 5,
871                            rotated: false,
872                        },
873                        Placement2D {
874                            name: "medium".to_string(),
875                            x: 5,
876                            y: 0,
877                            width: 2,
878                            height: 2,
879                            rotated: false,
880                        },
881                    ],
882                ),
883            ],
884            vec![
885                ItemInstance2D { name: "wide".to_string(), width: 6, height: 2, can_rotate: true },
886                ItemInstance2D { name: "tiny".to_string(), width: 1, height: 1, can_rotate: false },
887            ],
888            SolverMetrics2D { iterations: 3, explored_states: 2, notes: vec!["test".to_string()] },
889            0,
890        );
891
892        assert_eq!(solution.layouts[0].sheet_name, "alpha");
893        assert_eq!(solution.layouts[1].sheet_name, "beta");
894        assert_eq!(solution.unplaced[0].name, "wide");
895        assert_eq!(solution.total_cost, 3.0);
896        assert_eq!(solution.total_waste_area, 119);
897
898        let worse = TwoDSolution { sheet_count: solution.sheet_count + 1, ..solution.clone() };
899        assert!(solution.is_better_than(&worse));
900    }
901
902    /// Edge cases for `Rect` geometry helpers that the placement code relies
903    /// on. Each assertion pins down behavior that a plausible refactor could
904    /// silently flip (for example, changing `intersects` to use `<=` instead
905    /// of `<` would turn edge-touching rectangles into overlapping ones).
906    #[test]
907    fn rect_helpers_handle_boundary_cases() {
908        // Touching along a single edge does NOT count as intersecting.
909        // `left_right == right.x` means the strict `<` in `intersects` fails.
910        let left = Rect { x: 0, y: 0, width: 5, height: 5 };
911        let right = Rect { x: 5, y: 0, width: 5, height: 5 };
912        assert!(!left.intersects(right), "edge-touching rects are not intersecting");
913        assert!(!right.intersects(left), "intersects is symmetric for edge-touching rects");
914
915        // Touching at a single corner is also not intersecting.
916        let corner = Rect { x: 5, y: 5, width: 5, height: 5 };
917        assert!(!left.intersects(corner));
918
919        // Overlap by even one unit IS intersecting.
920        let overlap_by_one = Rect { x: 4, y: 0, width: 5, height: 5 };
921        assert!(left.intersects(overlap_by_one));
922
923        // Self-containment holds (contains uses `<=` and `>=`).
924        assert!(left.contains(left), "a rect should contain itself");
925
926        // Exact fit — `fits` uses `<=` so identical dimensions are accepted.
927        assert!(left.fits(5, 5));
928        // One unit larger in either dimension does not fit.
929        assert!(!left.fits(6, 5));
930        assert!(!left.fits(5, 6));
931
932        // Area widens to u64 so extreme dimensions do not overflow.
933        let huge = Rect { x: 0, y: 0, width: u32::MAX, height: u32::MAX };
934        assert_eq!(huge.area(), u64::from(u32::MAX) * u64::from(u32::MAX));
935    }
936
937    /// `TwoDSolution::is_better_than` compares on a 4-key tuple:
938    /// (unplaced count, sheet_count, total_waste_area, total_cost).
939    /// Verify each key is consulted in order as a tiebreaker.
940    #[test]
941    fn two_d_is_better_than_tie_breaks_on_each_key() {
942        let sheets = vec![Sheet2D {
943            name: "s".to_string(),
944            width: 10,
945            height: 10,
946            cost: 1.0,
947            quantity: None,
948            kerf: 0,
949            edge_kerf_relief: false,
950        }];
951        let base = TwoDSolution::from_layouts(
952            "test",
953            false,
954            &sheets,
955            vec![(
956                0,
957                vec![Placement2D {
958                    name: "x".to_string(),
959                    x: 0,
960                    y: 0,
961                    width: 5,
962                    height: 5,
963                    rotated: false,
964                }],
965            )],
966            Vec::new(),
967            SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
968            0,
969        );
970
971        // Fewer unplaced beats more unplaced (primary key).
972        let more_unplaced = TwoDSolution {
973            unplaced: vec![RectDemand2D {
974                name: "u".to_string(),
975                width: 1,
976                height: 1,
977                quantity: 1,
978                can_rotate: false,
979            }],
980            ..base.clone()
981        };
982        assert!(base.is_better_than(&more_unplaced));
983        assert!(!more_unplaced.is_better_than(&base));
984
985        // Fewer sheets wins when unplaced ties.
986        let more_sheets = TwoDSolution { sheet_count: base.sheet_count + 1, ..base.clone() };
987        assert!(base.is_better_than(&more_sheets));
988
989        // Less waste wins when unplaced and sheets tie.
990        let more_waste =
991            TwoDSolution { total_waste_area: base.total_waste_area + 100, ..base.clone() };
992        assert!(base.is_better_than(&more_waste));
993
994        // Lower cost wins when every preceding key ties.
995        let more_cost = TwoDSolution { total_cost: base.total_cost + 1.0, ..base.clone() };
996        assert!(base.is_better_than(&more_cost));
997
998        // Strictly equal solutions are not "better than" each other.
999        assert!(!base.is_better_than(&base));
1000    }
1001
1002    /// `ItemInstance2D::orientations` should collapse the rotated orientation
1003    /// to a no-op when the item is square, even if `can_rotate` is true.
1004    /// That avoids the solver double-evaluating an identical placement.
1005    #[test]
1006    fn item_orientations_collapse_squares_to_one_arm() {
1007        let square =
1008            ItemInstance2D { name: "square".to_string(), width: 5, height: 5, can_rotate: true };
1009        let orientations = square.orientations().collect::<Vec<_>>();
1010        assert_eq!(
1011            orientations.len(),
1012            1,
1013            "square with can_rotate=true should emit exactly one orientation"
1014        );
1015        assert_eq!(orientations[0], (5, 5, false));
1016
1017        let non_square =
1018            ItemInstance2D { name: "rect".to_string(), width: 3, height: 7, can_rotate: true };
1019        let orientations = non_square.orientations().collect::<Vec<_>>();
1020        assert_eq!(orientations.len(), 2);
1021        assert_eq!(orientations[0], (3, 7, false));
1022        assert_eq!(orientations[1], (7, 3, true));
1023
1024        let non_rotatable =
1025            ItemInstance2D { name: "rect".to_string(), width: 3, height: 7, can_rotate: false };
1026        let orientations = non_rotatable.orientations().collect::<Vec<_>>();
1027        assert_eq!(orientations.len(), 1);
1028        assert_eq!(orientations[0], (3, 7, false));
1029    }
1030
1031    #[test]
1032    fn sheet_kerf_defaults_to_zero_when_absent_from_json() {
1033        let sheet: Sheet2D =
1034            serde_json::from_value(json!({ "name": "s", "width": 10, "height": 10 }))
1035                .expect("sheet");
1036        assert_eq!(sheet.kerf, 0);
1037
1038        let sheet_with_kerf: Sheet2D =
1039            serde_json::from_value(json!({ "name": "s", "width": 10, "height": 10, "kerf": 3 }))
1040                .expect("sheet");
1041        assert_eq!(sheet_with_kerf.kerf, 3);
1042    }
1043
1044    #[test]
1045    fn validation_rejects_kerf_that_consumes_entire_sheet() {
1046        let bad = TwoDProblem {
1047            sheets: vec![Sheet2D {
1048                name: "thin".to_string(),
1049                width: 4,
1050                height: 20,
1051                cost: 1.0,
1052                quantity: None,
1053                kerf: 2,
1054                edge_kerf_relief: false,
1055            }],
1056            demands: vec![RectDemand2D {
1057                name: "x".to_string(),
1058                width: 1,
1059                height: 1,
1060                quantity: 1,
1061                can_rotate: false,
1062            }],
1063        };
1064        assert!(matches!(
1065            bad.validate(),
1066            Err(BinPackingError::InvalidInput(message))
1067                if message == "sheet `thin` kerf 2 is too large for shortest side 4"
1068        ));
1069    }
1070
1071    #[test]
1072    fn from_layouts_populates_zero_kerf_area_when_sheet_kerf_is_zero() {
1073        let sheets = vec![Sheet2D {
1074            name: "s".to_string(),
1075            width: 10,
1076            height: 10,
1077            cost: 1.0,
1078            quantity: None,
1079            kerf: 0,
1080            edge_kerf_relief: false,
1081        }];
1082        let solution = TwoDSolution::from_layouts(
1083            "test",
1084            false,
1085            &sheets,
1086            vec![(
1087                0,
1088                vec![Placement2D {
1089                    name: "a".to_string(),
1090                    x: 0,
1091                    y: 0,
1092                    width: 5,
1093                    height: 5,
1094                    rotated: false,
1095                }],
1096            )],
1097            Vec::new(),
1098            SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
1099            0,
1100        );
1101        assert_eq!(solution.layouts[0].kerf_area, 0);
1102        assert_eq!(solution.total_kerf_area, 0);
1103    }
1104
1105    #[test]
1106    fn two_d_options_min_usable_side_defaults_to_zero_when_absent_from_json() {
1107        let options: TwoDOptions = serde_json::from_value(json!({})).expect("options");
1108        assert_eq!(options.min_usable_side, 0);
1109
1110        let options_with_threshold: TwoDOptions =
1111            serde_json::from_value(json!({ "min_usable_side": 12 })).expect("options");
1112        assert_eq!(options_with_threshold.min_usable_side, 12);
1113    }
1114
1115    #[test]
1116    fn from_layouts_populates_zero_consolidation_metrics_when_threshold_is_zero_and_kerf_is_zero() {
1117        // With min_usable_side=0 and a single full-sheet placement leaving zero
1118        // waste, the consolidation metrics on the layout must all be zero.
1119        let sheets = vec![Sheet2D {
1120            name: "s".to_string(),
1121            width: 10,
1122            height: 10,
1123            cost: 1.0,
1124            quantity: None,
1125            kerf: 0,
1126            edge_kerf_relief: false,
1127        }];
1128        let solution = TwoDSolution::from_layouts(
1129            "test",
1130            false,
1131            &sheets,
1132            vec![(
1133                0,
1134                vec![Placement2D {
1135                    name: "a".to_string(),
1136                    x: 0,
1137                    y: 0,
1138                    width: 10,
1139                    height: 10,
1140                    rotated: false,
1141                }],
1142            )],
1143            Vec::new(),
1144            SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
1145            /* min_usable_side */ 0,
1146        );
1147        assert_eq!(solution.layouts[0].largest_usable_drop_area, 0);
1148        assert_eq!(solution.layouts[0].sum_sq_usable_drop_areas, 0);
1149        assert_eq!(solution.max_usable_drop_area, 0);
1150        assert_eq!(solution.total_sum_sq_usable_drop_areas, 0);
1151    }
1152
1153    /// Helper: build a minimal `TwoDSolution` by overriding the consolidation
1154    /// fields on a base solution. `from_layouts` does not expose those fields
1155    /// directly, so we build via `from_layouts` (which populates them from
1156    /// `drops::usable_drop_metrics`) and then reconstruct with struct-update
1157    /// syntax to test specific metric values.
1158    fn make_solution_with_metrics(
1159        max_drop: u64,
1160        sum_sq: u128,
1161        waste_area: u64,
1162        cost: f64,
1163    ) -> TwoDSolution {
1164        // A 100×100 sheet; place nothing so waste = 10000.  We then
1165        // override the consolidation and waste fields to the desired values.
1166        let sheets = vec![Sheet2D {
1167            name: "s".to_string(),
1168            width: 100,
1169            height: 100,
1170            cost,
1171            quantity: None,
1172            kerf: 0,
1173            edge_kerf_relief: false,
1174        }];
1175        let base = TwoDSolution::from_layouts(
1176            "test",
1177            false,
1178            &sheets,
1179            vec![(0, Vec::new())],
1180            Vec::new(),
1181            SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
1182            0,
1183        );
1184        TwoDSolution {
1185            max_usable_drop_area: max_drop,
1186            total_sum_sq_usable_drop_areas: sum_sq,
1187            total_waste_area: waste_area,
1188            total_cost: cost,
1189            ..base
1190        }
1191    }
1192
1193    /// `is_better_than` must prefer the solution with the larger
1194    /// `max_usable_drop_area` when all primary keys are equal.
1195    #[test]
1196    fn consolidation_tiebreak_picks_larger_drop() {
1197        // Both solutions: 0 unplaced, 1 sheet, waste=100, cost=1.0.
1198        // A has max_drop=80; B has max_drop=40.  A should be preferred.
1199        let a = make_solution_with_metrics(80, 6_400, 100, 1.0);
1200        let b = make_solution_with_metrics(40, 1_600, 100, 1.0);
1201
1202        assert!(a.is_better_than(&b), "larger max_usable_drop_area should win the tiebreak");
1203        assert!(!b.is_better_than(&a), "smaller max_usable_drop_area should lose the tiebreak");
1204        // Strictly equal: neither is better.
1205        assert!(!a.is_better_than(&a));
1206    }
1207
1208    /// When `max_usable_drop_area` is also tied, `total_sum_sq_usable_drop_areas`
1209    /// is the next tiebreaker: higher sum-of-squares wins.
1210    #[test]
1211    fn consolidation_tiebreak_picks_more_concentrated_sum_sq() {
1212        // Both solutions: same unplaced, sheets, max_drop=50, waste=100, cost=1.0.
1213        // A has sum_sq=2500 (consolidated); B has sum_sq=500 (fragmented).
1214        let a = make_solution_with_metrics(50, 2_500, 100, 1.0);
1215        let b = make_solution_with_metrics(50, 500, 100, 1.0);
1216
1217        assert!(
1218            a.is_better_than(&b),
1219            "higher sum_sq should win the tiebreak when max_drop is tied"
1220        );
1221        assert!(
1222            !b.is_better_than(&a),
1223            "lower sum_sq should lose the tiebreak when max_drop is tied"
1224        );
1225    }
1226
1227    /// `total_waste_area` must still beat the consolidation tiebreaker:
1228    /// a solution with lower waste must win even if it has a tiny drop.
1229    #[test]
1230    fn waste_area_beats_consolidation() {
1231        // A: waste=100, max_drop=5 (tiny drop)
1232        // B: waste=101, max_drop=90 (huge drop)
1233        // A must win because waste is checked before consolidation.
1234        let a = make_solution_with_metrics(5, 25, 100, 1.0);
1235        let b = make_solution_with_metrics(90, 8_100, 101, 1.0);
1236
1237        assert!(a.is_better_than(&b), "lower waste_area must beat better consolidation");
1238        assert!(!b.is_better_than(&a), "better consolidation must not override lower waste_area");
1239    }
1240
1241    #[test]
1242    fn sheet_edge_kerf_relief_defaults_to_false_when_absent() {
1243        let sheet: Sheet2D =
1244            serde_json::from_value(json!({ "name": "s", "width": 10, "height": 10 }))
1245                .expect("sheet");
1246        assert!(!sheet.edge_kerf_relief);
1247
1248        let sheet_with_relief: Sheet2D = serde_json::from_value(json!({
1249            "name": "s",
1250            "width": 10,
1251            "height": 10,
1252            "edge_kerf_relief": true
1253        }))
1254        .expect("sheet");
1255        assert!(sheet_with_relief.edge_kerf_relief);
1256    }
1257
1258    #[test]
1259    fn effective_bounds_returns_sheet_dims_when_relief_off() {
1260        let s = Sheet2D {
1261            name: "s".into(),
1262            width: 100,
1263            height: 50,
1264            cost: 1.0,
1265            quantity: None,
1266            kerf: 3,
1267            edge_kerf_relief: false,
1268        };
1269        assert_eq!(effective_bounds(&s), (100, 50));
1270    }
1271
1272    #[test]
1273    fn effective_bounds_pads_by_kerf_when_relief_on() {
1274        let s = Sheet2D {
1275            name: "s".into(),
1276            width: 100,
1277            height: 50,
1278            cost: 1.0,
1279            quantity: None,
1280            kerf: 3,
1281            edge_kerf_relief: true,
1282        };
1283        assert_eq!(effective_bounds(&s), (103, 53));
1284    }
1285
1286    #[test]
1287    fn effective_bounds_saturates_at_u32_max() {
1288        let s = Sheet2D {
1289            name: "s".into(),
1290            width: u32::MAX,
1291            height: u32::MAX,
1292            cost: 1.0,
1293            quantity: None,
1294            kerf: 10,
1295            edge_kerf_relief: true,
1296        };
1297        assert_eq!(effective_bounds(&s), (u32::MAX, u32::MAX));
1298    }
1299
1300    #[test]
1301    fn from_layouts_clips_used_area_for_overrun_placements() {
1302        // Two 24-wide placements on a 48-wide sheet with kerf=1 and edge
1303        // relief enabled. Second placement spans x=25..49, overrunning by 1.
1304        let sheets = vec![Sheet2D {
1305            name: "s".into(),
1306            width: 48,
1307            height: 10,
1308            cost: 1.0,
1309            quantity: None,
1310            kerf: 1,
1311            edge_kerf_relief: true,
1312        }];
1313        let placements = vec![
1314            Placement2D { name: "a".into(), x: 0, y: 0, width: 24, height: 10, rotated: false },
1315            Placement2D { name: "b".into(), x: 25, y: 0, width: 24, height: 10, rotated: false },
1316        ];
1317
1318        let solution = TwoDSolution::from_layouts(
1319            "test",
1320            true,
1321            &sheets,
1322            vec![(0, placements)],
1323            Vec::new(),
1324            SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
1325            0,
1326        );
1327
1328        let layout = &solution.layouts[0];
1329        let sheet_area = u64::from(48_u32) * u64::from(10_u32);
1330        assert!(
1331            layout.used_area <= sheet_area,
1332            "used_area {} must not exceed sheet area {}",
1333            layout.used_area,
1334            sheet_area
1335        );
1336        // Part A contributes 24*10 = 240. Part B's on-sheet portion is
1337        // 23*10 = 230 (x=25..48 clipped from x=25..49). Sum = 470.
1338        assert_eq!(layout.used_area, 470);
1339    }
1340}