1use serde::{Deserialize, Serialize};
4
5use crate::{BinPackingError, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(rename_all = "snake_case")]
10pub enum TwoDAlgorithm {
11 #[default]
13 Auto,
14 MaxRects,
16 MaxRectsBestShortSideFit,
18 MaxRectsBestLongSideFit,
20 MaxRectsBottomLeft,
22 MaxRectsContactPoint,
24 Skyline,
26 SkylineMinWaste,
28 Guillotine,
30 GuillotineBestShortSideFit,
32 GuillotineBestLongSideFit,
34 GuillotineShorterLeftoverAxis,
36 GuillotineLongerLeftoverAxis,
38 GuillotineMinAreaSplit,
40 GuillotineMaxAreaSplit,
42 NextFitDecreasingHeight,
44 FirstFitDecreasingHeight,
46 BestFitDecreasingHeight,
48 MultiStart,
50 RotationSearch,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct Sheet2D {
60 pub name: String,
62 pub width: u32,
64 pub height: u32,
66 #[serde(default = "default_sheet_cost")]
68 pub cost: f64,
69 #[serde(default)]
71 pub quantity: Option<usize>,
72 #[serde(default)]
78 pub kerf: u32,
79 #[serde(default)]
87 pub edge_kerf_relief: bool,
88}
89
90pub(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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RectDemand2D {
106 pub name: String,
108 pub width: u32,
110 pub height: u32,
112 pub quantity: usize,
114 #[serde(default = "default_can_rotate")]
116 pub can_rotate: bool,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Placement2D {
122 pub name: String,
124 pub x: u32,
126 pub y: u32,
128 pub width: u32,
130 pub height: u32,
132 pub rotated: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct SheetLayout2D {
139 pub sheet_name: String,
141 pub width: u32,
143 pub height: u32,
145 pub cost: f64,
147 pub placements: Vec<Placement2D>,
149 pub used_area: u64,
151 pub waste_area: u64,
153 pub kerf_area: u64,
156 pub largest_usable_drop_area: u64,
160 pub sum_sq_usable_drop_areas: u128,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SolverMetrics2D {
170 pub iterations: usize,
172 pub explored_states: usize,
174 pub notes: Vec<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct TwoDSolution {
181 pub algorithm: String,
183 pub guillotine: bool,
185 pub sheet_count: usize,
187 pub total_waste_area: u64,
189 pub total_kerf_area: u64,
191 pub total_cost: f64,
193 pub max_usable_drop_area: u64,
196 pub total_sum_sq_usable_drop_areas: u128,
200 pub layouts: Vec<SheetLayout2D>,
202 pub unplaced: Vec<RectDemand2D>,
204 pub metrics: SolverMetrics2D,
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct TwoDProblem {
211 pub sheets: Vec<Sheet2D>,
213 pub demands: Vec<RectDemand2D>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct TwoDOptions {
220 #[serde(default)]
222 pub algorithm: TwoDAlgorithm,
223 #[serde(default = "default_multistart_runs")]
225 pub multistart_runs: usize,
226 #[serde(default = "default_beam_width")]
228 pub beam_width: usize,
229 #[serde(default)]
231 pub guillotine_required: bool,
232 #[serde(default)]
234 pub seed: Option<u64>,
235 #[serde(default)]
245 pub min_usable_side: u32,
246 #[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 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 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 #[test]
907 fn rect_helpers_handle_boundary_cases() {
908 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 let corner = Rect { x: 5, y: 5, width: 5, height: 5 };
917 assert!(!left.intersects(corner));
918
919 let overlap_by_one = Rect { x: 4, y: 0, width: 5, height: 5 };
921 assert!(left.intersects(overlap_by_one));
922
923 assert!(left.contains(left), "a rect should contain itself");
925
926 assert!(left.fits(5, 5));
928 assert!(!left.fits(6, 5));
930 assert!(!left.fits(5, 6));
931
932 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 #[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 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 let more_sheets = TwoDSolution { sheet_count: base.sheet_count + 1, ..base.clone() };
987 assert!(base.is_better_than(&more_sheets));
988
989 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 let more_cost = TwoDSolution { total_cost: base.total_cost + 1.0, ..base.clone() };
996 assert!(base.is_better_than(&more_cost));
997
998 assert!(!base.is_better_than(&base));
1000 }
1001
1002 #[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 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 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 fn make_solution_with_metrics(
1159 max_drop: u64,
1160 sum_sq: u128,
1161 waste_area: u64,
1162 cost: f64,
1163 ) -> TwoDSolution {
1164 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 #[test]
1196 fn consolidation_tiebreak_picks_larger_drop() {
1197 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 assert!(!a.is_better_than(&a));
1206 }
1207
1208 #[test]
1211 fn consolidation_tiebreak_picks_more_concentrated_sum_sq() {
1212 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 #[test]
1230 fn waste_area_beats_consolidation() {
1231 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 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 assert_eq!(layout.used_area, 470);
1339 }
1340}