1use crate::boundary::Boundary3D;
4use crate::brkga_packing::run_brkga_packing;
5use crate::extreme_point::run_ep_packing;
6use crate::ga_packing::run_ga_packing;
7use crate::geometry::Geometry3D;
8use crate::packing_utils::{build_unplaced_list, layer_fill_in_order};
9use crate::physics::{PhysicsConfig, PhysicsSimulator};
10use crate::sa_packing::run_sa_packing;
11use crate::stability::{PlacedBox, StabilityAnalyzer, StabilityConstraint, StabilityReport};
12use u_nesting_core::brkga::BrkgaConfig;
13use u_nesting_core::ga::GaConfig;
14use u_nesting_core::geom::nalgebra_types::{NaPoint3 as Point3, NaVector3 as Vector3};
15use u_nesting_core::geometry::{Boundary, Geometry};
16use u_nesting_core::sa::SaConfig;
17use u_nesting_core::solver::{Config, ProgressCallback, ProgressInfo, Solver, Strategy};
18use u_nesting_core::{Error, Placement, Result, SolveResult};
19
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::Arc;
22use u_nesting_core::timing::Timer;
23
24const GA_POPULATION: usize = 100;
28
29const GA_GENERATIONS: u32 = 500;
32
33const BRKGA_POPULATION: usize = 50;
36
37const BRKGA_GENERATIONS: u32 = 100;
40
41pub struct Packer3D {
43 config: Config,
44 cancelled: Arc<AtomicBool>,
45}
46
47impl Packer3D {
48 fn run_strategy(
51 &self,
52 geometries: &[Geometry3D],
53 boundary: &Boundary3D,
54 ) -> Result<SolveResult<f64>> {
55 match self.config.strategy {
56 Strategy::BottomLeftFill => self.layer_packing(geometries, boundary),
57 Strategy::ExtremePoint => self.extreme_point(geometries, boundary),
58 Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
59 Strategy::Brkga => self.brkga(geometries, boundary),
60 Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
61 other => Err(Error::invalid_option(
62 "strategy",
63 format!(
64 "strategy {other:?} is not available for 3D packing; it offers \
65 BottomLeftFill, ExtremePoint, GeneticAlgorithm, Brkga and SimulatedAnnealing"
66 ),
67 )),
68 }
69 }
70
71 pub fn new(config: Config) -> Self {
73 Self {
74 config,
75 cancelled: Arc::new(AtomicBool::new(false)),
76 }
77 }
78
79 pub fn default_config() -> Self {
81 Self::new(Config::default())
82 }
83
84 fn ga_config(&self) -> GaConfig {
88 GaConfig::default()
89 .with_population_size(self.config.population_size.unwrap_or(GA_POPULATION))
90 .with_max_generations(self.config.max_generations.unwrap_or(GA_GENERATIONS))
91 .with_crossover_rate(self.config.crossover_rate)
92 .with_mutation_rate(self.config.mutation_rate)
93 .with_elite_count(self.config.elite_count)
94 }
95
96 fn brkga_config(&self) -> BrkgaConfig {
100 BrkgaConfig::default()
101 .with_population_size(self.config.population_size.unwrap_or(BRKGA_POPULATION))
102 .with_max_generations(self.config.max_generations.unwrap_or(BRKGA_GENERATIONS))
103 .with_elite_fraction(0.2)
104 .with_mutant_fraction(0.15)
105 .with_elite_bias(0.7)
106 }
107
108 fn validate_inputs(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<()> {
112 boundary.validate()?;
113 if geometries.is_empty() {
114 return Err(Error::empty_input("geometries"));
115 }
116 self.config.validate()?;
117 for geom in geometries {
118 geom.validate()?;
119 }
120 u_nesting_core::geometry::ensure_unique_ids(geometries)
121 }
122
123 pub fn validate_stability(
131 &self,
132 result: &SolveResult<f64>,
133 geometries: &[Geometry3D],
134 _boundary: &Boundary3D,
135 constraint: StabilityConstraint,
136 ) -> Result<StabilityReport> {
137 let placed_boxes = self.placements_to_boxes(result, geometries);
139 let analyzer = StabilityAnalyzer::new(constraint)?;
140 Ok(analyzer.analyze(&placed_boxes, 0.0))
141 }
142
143 pub fn validate_stability_physics(
147 &self,
148 result: &SolveResult<f64>,
149 geometries: &[Geometry3D],
150 boundary: &Boundary3D,
151 ) -> StabilityReport {
152 let placed_boxes = self.placements_to_boxes(result, geometries);
153 let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
154
155 let config = PhysicsConfig::default().with_max_time(2.0);
156 let simulator =
157 PhysicsSimulator::new(config).expect("the default physics settings are in range");
158 simulator.validate_stability(&placed_boxes, container, 0.0)
159 }
160
161 fn enforce_support(
172 &self,
173 result: &mut SolveResult<f64>,
174 geometries: &[Geometry3D],
175 boundary: &Boundary3D,
176 ) {
177 let constraint = if boundary.has_stability() {
180 StabilityConstraint::partial_base(0.7)
181 } else {
182 StabilityConstraint::partial_base(1e-6)
183 };
184
185 let floor_z = self.config.margin;
189 let analyzer = StabilityAnalyzer::new(constraint)
190 .expect("the support ratios used here, 0.7 and 1e-6, are in range");
191 while !result.placements.is_empty() {
192 let placed_boxes = self.placements_to_boxes(result, geometries);
193 let report = analyzer.analyze(&placed_boxes, floor_z);
194 if report.is_all_stable() {
195 break;
196 }
197 let drop: std::collections::HashSet<(String, usize)> = report
198 .unstable_boxes()
199 .iter()
200 .map(|r| (r.id.clone(), r.instance))
201 .collect();
202 result
203 .placements
204 .retain(|p| !drop.contains(&(p.geometry_id.clone(), p.instance)));
205 for (id, _) in drop {
206 result.unplaced.push(id);
207 }
208 }
209
210 result.deduplicate_unplaced();
211 let boxes = self.placements_to_boxes(result, geometries);
213 let placed_volume: f64 = boxes
214 .iter()
215 .map(|b| b.dimensions.x * b.dimensions.y * b.dimensions.z)
216 .sum();
217 let container_volume = boundary.measure();
218 result.utilization = if container_volume > 0.0 {
219 placed_volume / container_volume
220 } else {
221 0.0
222 };
223 }
224
225 fn placements_to_boxes(
227 &self,
228 result: &SolveResult<f64>,
229 geometries: &[Geometry3D],
230 ) -> Vec<PlacedBox> {
231 let geom_map: std::collections::HashMap<&str, &Geometry3D> =
232 geometries.iter().map(|g| (g.id().as_str(), g)).collect();
233
234 result
235 .placements
236 .iter()
237 .filter_map(|p| {
238 let geom = geom_map.get(p.geometry_id.as_str())?;
239 let ori_idx = p.rotation_index.unwrap_or(0);
240 let dims = geom.dimensions_for_orientation(ori_idx);
241
242 let mut placed = PlacedBox::new(
243 p.geometry_id.clone(),
244 p.instance,
245 Point3::new(p.position[0], p.position[1], p.position[2]),
246 dims,
247 );
248
249 if let Some(mass) = geom.mass() {
250 placed = placed.with_mass(mass);
251 }
252
253 Some(placed)
254 })
255 .collect()
256 }
257
258 fn layer_packing(
261 &self,
262 geometries: &[Geometry3D],
263 boundary: &Boundary3D,
264 ) -> Result<SolveResult<f64>> {
265 Ok(self.layer_fill(geometries, boundary, None))
266 }
267
268 fn layer_fill(
271 &self,
272 geometries: &[Geometry3D],
273 boundary: &Boundary3D,
274 callback: Option<&ProgressCallback>,
275 ) -> SolveResult<f64> {
276 let start = Timer::now();
277 let total: usize = geometries.iter().map(|g| g.quantity()).sum();
278 let report = |info: ProgressInfo| {
279 if let Some(callback) = callback {
280 callback(info);
281 }
282 };
283 report(
284 ProgressInfo::new()
285 .with_phase("Layer Packing")
286 .with_items(0, total)
287 .with_elapsed(0),
288 );
289
290 let mut cancelled = false;
291 let mut timed_out = false;
292 let (fill, _stopped) = layer_fill_in_order(
293 geometries,
294 boundary,
295 &self.config,
296 || {
297 cancelled = self.cancelled.load(Ordering::Relaxed);
298 timed_out = self.config.time_limit_ms > 0
299 && start.elapsed_ms() >= self.config.time_limit_ms;
300 cancelled || timed_out
301 },
302 |fill| {
303 report(
304 ProgressInfo::new()
305 .with_phase("Layer Packing")
306 .with_items(fill.placed(), total)
307 .with_utilization(fill.utilization())
308 .with_elapsed(start.elapsed_ms()),
309 )
310 },
311 );
312
313 let mut result = SolveResult::new();
314 result.unplaced = build_unplaced_list(&fill.placements, geometries);
315 result.boundaries_used = usize::from(!fill.placements.is_empty());
316 result.utilization = fill.utilization;
317 result.placements = fill.placements;
318 result.cancelled = cancelled;
319 result.computation_time_ms = start.elapsed_ms();
320 let phase = if cancelled {
321 "Cancelled"
322 } else if timed_out {
323 "Time Limit Reached"
324 } else {
325 "Complete"
326 };
327 report(
328 ProgressInfo::new()
329 .with_phase(phase)
330 .with_items(result.placements.len(), total)
331 .with_utilization(result.utilization)
332 .with_elapsed(result.computation_time_ms)
333 .finished(),
334 );
335 result
336 }
337
338 fn search_time_limit(&self, started: &Timer) -> Option<std::time::Duration> {
341 (self.config.time_limit_ms > 0).then(|| {
342 std::time::Duration::from_millis(
343 self.config
344 .time_limit_ms
345 .saturating_sub(started.elapsed_ms())
346 .max(1),
347 )
348 })
349 }
350
351 fn not_worse_than_fills(
361 &self,
362 search: SolveResult<f64>,
363 geometries: &[Geometry3D],
364 boundary: &Boundary3D,
365 layer_fill: SolveResult<f64>,
366 ) -> SolveResult<f64> {
367 let supported = |mut r: SolveResult<f64>| {
368 if boundary.has_gravity() || boundary.has_stability() {
369 self.enforce_support(&mut r, geometries, boundary);
370 }
371 r
372 };
373 let fills = [
374 Some(layer_fill),
375 self.extreme_point(geometries, boundary).ok(),
376 ]
377 .into_iter()
378 .flatten()
379 .map(supported);
380 supported(search).floored_at(fills, |a, b| {
381 let (pa, pb) = (a.placements.len(), b.placements.len());
382 pa > pb || (pa == pb && a.utilization > b.utilization + 1e-9)
383 })
384 }
385
386 fn genetic_algorithm(
391 &self,
392 geometries: &[Geometry3D],
393 boundary: &Boundary3D,
394 ) -> Result<SolveResult<f64>> {
395 let started = Timer::now();
396 let fill = self.layer_fill(geometries, boundary, None);
397 let mut ga_config = self.ga_config();
398 ga_config.time_limit = self.search_time_limit(&started);
399 let result = run_ga_packing(
400 geometries,
401 boundary,
402 &self.config,
403 ga_config,
404 self.cancelled.clone(),
405 Some(&fill.placements),
406 )?;
407 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
408 }
409
410 fn brkga(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<SolveResult<f64>> {
415 let started = Timer::now();
416 let fill = self.layer_fill(geometries, boundary, None);
417 let mut brkga_config = self.brkga_config();
418 brkga_config.time_limit = self.search_time_limit(&started);
419 let result = run_brkga_packing(
420 geometries,
421 boundary,
422 &self.config,
423 brkga_config,
424 self.cancelled.clone(),
425 Some(&fill.placements),
426 )?;
427 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
428 }
429
430 fn simulated_annealing(
435 &self,
436 geometries: &[Geometry3D],
437 boundary: &Boundary3D,
438 ) -> Result<SolveResult<f64>> {
439 let started = Timer::now();
440 let fill = self.layer_fill(geometries, boundary, None);
441 let mut sa_config = SaConfig::default()
442 .with_initial_temp(100.0)
443 .with_final_temp(0.1)
444 .with_cooling_rate(0.95)
445 .with_iterations_per_temp(50)
446 .with_max_iterations(10000);
447 sa_config.time_limit = self.search_time_limit(&started);
448
449 let result = run_sa_packing(
450 geometries,
451 boundary,
452 &self.config,
453 sa_config,
454 self.cancelled.clone(),
455 Some(&fill.placements),
456 )?;
457 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
458 }
459
460 fn extreme_point(
465 &self,
466 geometries: &[Geometry3D],
467 boundary: &Boundary3D,
468 ) -> Result<SolveResult<f64>> {
469 let start = Timer::now();
470
471 let (ep_placements, utilization) = run_ep_packing(
472 geometries,
473 boundary,
474 self.config.margin,
475 self.config.spacing,
476 boundary.max_mass(),
477 );
478
479 let mut placements = Vec::new();
484 for (id, instance, position, orientation) in ep_placements {
485 let placement = Placement::new_3d(
486 id, instance, position.x, position.y, position.z, 0.0, 0.0, 0.0, )
490 .with_rotation_index(orientation);
491 placements.push(placement);
492 }
493
494 let unplaced = build_unplaced_list(&placements, geometries);
495
496 let mut result = SolveResult::new();
497 result.placements = placements;
498 result.boundaries_used = 1;
499 result.utilization = utilization;
500 result.unplaced = unplaced;
501 result.computation_time_ms = start.elapsed_ms();
502 result.strategy = Some("ExtremePoint".to_string());
503
504 Ok(result)
505 }
506}
507
508impl Solver for Packer3D {
509 type Geometry = Geometry3D;
510 type Boundary = Boundary3D;
511 type Scalar = f64;
512
513 fn solve(
514 &self,
515 geometries: &[Self::Geometry],
516 boundary: &Self::Boundary,
517 ) -> Result<SolveResult<f64>> {
518 self.validate_inputs(geometries, boundary)?;
519
520 self.cancelled.store(false, Ordering::Relaxed);
522
523 let mut result = self.run_strategy(geometries, boundary)?;
524
525 result.deduplicate_unplaced();
527 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
530
531 if boundary.has_gravity() || boundary.has_stability() {
533 self.enforce_support(&mut result, geometries, boundary);
534 }
535 Ok(result)
536 }
537
538 fn solve_with_progress(
539 &self,
540 geometries: &[Self::Geometry],
541 boundary: &Self::Boundary,
542 callback: ProgressCallback,
543 ) -> Result<SolveResult<f64>> {
544 self.validate_inputs(geometries, boundary)?;
545
546 self.cancelled.store(false, Ordering::Relaxed);
548
549 let mut result = match self.config.strategy {
550 Strategy::BottomLeftFill => self.layer_fill(geometries, boundary, Some(&callback)),
551 Strategy::ExtremePoint => self.extreme_point(geometries, boundary)?,
554 _ => self.run_strategy(geometries, boundary)?,
558 };
559
560 result.deduplicate_unplaced();
562 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
565
566 if boundary.has_gravity() || boundary.has_stability() {
568 self.enforce_support(&mut result, geometries, boundary);
569 }
570 Ok(result)
571 }
572
573 fn cancel(&self) {
574 self.cancelled.store(true, Ordering::Relaxed);
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
584 let geometries = [
585 Geometry3D::new("B", 10.0, 10.0, 10.0),
586 Geometry3D::new("B", 50.0, 50.0, 50.0),
587 ];
588 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
589 let packer = Packer3D::default_config();
590 let errors = [
591 packer.solve(&geometries, &boundary).expect_err("solve"),
592 packer
593 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
594 .expect_err("solve_with_progress"),
595 ];
596 for err in errors {
597 let msg = err.to_string();
598 assert!(msg.contains("'B'"), "{msg}");
599 assert!(msg.contains("positions 0 and 1"), "{msg}");
600 }
601 }
602
603 #[test]
608 fn every_entry_point_judges_the_whole_request() {
609 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
610 let packer = Packer3D::default_config();
611 let empty = Error::EmptyInput {
612 parameter: "geometries".into(),
613 };
614 assert_eq!(packer.solve(&[], &boundary).expect_err("solve"), empty);
615 assert_eq!(
616 packer
617 .solve_with_progress(&[], &boundary, Box::new(|_| {}))
618 .expect_err("solve_with_progress"),
619 empty
620 );
621
622 let boxes = [Geometry3D::new("B", 10.0, 10.0, 10.0)];
623 let bad_config = Packer3D::new(Config::default().with_spacing(-1.0));
624 for err in [
625 bad_config.solve(&boxes, &boundary).expect_err("solve"),
626 bad_config
627 .solve_with_progress(&boxes, &boundary, Box::new(|_| {}))
628 .expect_err("solve_with_progress"),
629 ] {
630 assert_eq!(err.code(), "parameter_out_of_range", "{err}");
631 }
632
633 let flat = [Geometry3D::new("flat", 10.0, 0.0, 10.0)];
634 for strategy in [Strategy::ExtremePoint, Strategy::GeneticAlgorithm] {
635 let packer = Packer3D::new(Config::default().with_strategy(strategy));
636 match packer.solve(&flat, &boundary) {
637 Err(Error::OutOfRange { parameter, id, .. }) => {
638 assert_eq!(parameter, "dimensions[1]");
639 assert_eq!(id.as_deref(), Some("flat"));
640 }
641 other => panic!("{strategy:?}: {other:?}"),
642 }
643 }
644 }
645
646 #[test]
650 fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
651 let unset = Packer3D::default_config();
652 assert_eq!(unset.ga_config().population_size, 100);
653 assert_eq!(unset.ga_config().max_generations, 500);
654 assert_eq!(unset.brkga_config().population_size, 50);
655 assert_eq!(unset.brkga_config().max_generations, 100);
656
657 let set = Packer3D::new(
658 Config::default()
659 .with_population_size(12)
660 .with_max_generations(3),
661 );
662 assert_eq!(set.ga_config().population_size, 12);
663 assert_eq!(set.ga_config().max_generations, 3);
664 assert_eq!(set.brkga_config().population_size, 12);
665 assert_eq!(set.brkga_config().max_generations, 3);
666 }
667
668 #[test]
669 fn test_simple_packing() {
670 let geometries = vec![
671 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
672 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
673 ];
674
675 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
676 let packer = Packer3D::default_config();
677
678 let result = packer.solve(&geometries, &boundary).unwrap();
679
680 assert!(result.utilization > 0.0);
681 assert!(result.placements.len() <= 5);
682 }
683
684 #[test]
685 fn test_mass_constraint() {
686 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
687 .with_quantity(10)
688 .with_mass(100.0)];
689
690 let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
691
692 let packer = Packer3D::default_config();
693 let result = packer.solve(&geometries, &boundary).unwrap();
694
695 assert!(result.placements.len() <= 3);
697 }
698
699 #[test]
700 fn test_placement_within_bounds() {
701 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
702
703 let boundary = Boundary3D::new(50.0, 50.0, 50.0);
704 let config = Config::default().with_margin(5.0).with_spacing(2.0);
705 let packer = Packer3D::new(config);
706
707 let result = packer.solve(&geometries, &boundary).unwrap();
708
709 assert_eq!(result.placements.len(), 4);
711 assert!(result.unplaced.is_empty());
712
713 for p in &result.placements {
715 assert!(p.position[0] >= 5.0);
716 assert!(p.position[1] >= 5.0);
717 assert!(p.position[2] >= 5.0);
718 }
719 }
720
721 #[test]
722 fn test_ga_strategy_basic() {
723 let geometries = vec![
724 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
725 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
726 ];
727
728 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
729 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
730 let packer = Packer3D::new(config);
731
732 let result = packer.solve(&geometries, &boundary).unwrap();
733
734 assert!(result.utilization > 0.0);
736 assert!(!result.placements.is_empty());
737 }
738
739 #[test]
740 fn test_ga_strategy_all_placed() {
741 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
743
744 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
745 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
746 let packer = Packer3D::new(config);
747
748 let result = packer.solve(&geometries, &boundary).unwrap();
749
750 assert_eq!(result.placements.len(), 4);
752 assert!(result.unplaced.is_empty());
753 }
754
755 #[test]
756 #[cfg(feature = "serde")]
757 fn test_3d_response_orientation_in_bounds() {
758 use crate::build_pack3d_response;
759 use crate::geometry::OrientationConstraint;
760
761 let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
767 let boundary = Boundary3D::new(bw, bd, bh);
768
769 for strategy in [
770 Strategy::ExtremePoint,
771 Strategy::GeneticAlgorithm,
772 Strategy::Brkga,
773 ] {
774 let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
775 .with_quantity(3)
776 .with_orientation(OrientationConstraint::Any)];
777 let config = Config::default().with_strategy(strategy);
778 let packer = Packer3D::new(config);
779 let result = packer.solve(&geometries, &boundary).unwrap();
780 let response = build_pack3d_response(&result, &geometries);
781
782 if strategy == Strategy::ExtremePoint {
784 assert!(
785 !response.placements.is_empty(),
786 "EP should place the tall box by rotating it into the flat container"
787 );
788 }
789
790 let base = geometries[0].dimensions_for_orientation(0); for p in &response.placements {
792 let axes: Vec<usize> = p
793 .orientation
794 .chars()
795 .map(|c| match c {
796 'x' => 0,
797 'y' => 1,
798 'z' => 2,
799 _ => panic!("unexpected orientation label '{}'", p.orientation),
800 })
801 .collect();
802 let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
803 let e = 1e-6;
804 assert!(
805 p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
806 "{:?} {}#{} out of bounds for reported orientation '{}': \
807 pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
808 strategy,
809 p.geometry_id,
810 p.instance,
811 p.orientation,
812 p.x,
813 p.y,
814 p.z,
815 );
816 }
817 }
818 }
819
820 #[test]
821 fn test_ga_strategy_with_orientations() {
822 use crate::geometry::OrientationConstraint;
823
824 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
826 .with_quantity(2)
827 .with_orientation(OrientationConstraint::Any)];
828
829 let boundary = Boundary3D::new(60.0, 60.0, 60.0);
831 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
832 let packer = Packer3D::new(config);
833
834 let result = packer.solve(&geometries, &boundary).unwrap();
835
836 assert_eq!(result.placements.len(), 2);
838 }
839
840 #[test]
841 fn test_brkga_strategy_basic() {
842 let geometries = vec![
843 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
844 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
845 ];
846
847 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
848 let config = Config::default().with_strategy(Strategy::Brkga);
849 let packer = Packer3D::new(config);
850
851 let result = packer.solve(&geometries, &boundary).unwrap();
852
853 assert!(result.utilization > 0.0);
855 assert!(!result.placements.is_empty());
856 assert_eq!(result.strategy, Some("BRKGA".to_string()));
857 }
858
859 #[test]
860 fn test_brkga_strategy_all_placed() {
861 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
863
864 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
865 let config = Config::default().with_strategy(Strategy::Brkga);
866 let packer = Packer3D::new(config);
867
868 let result = packer.solve(&geometries, &boundary).unwrap();
869
870 assert_eq!(result.placements.len(), 4);
872 assert!(result.unplaced.is_empty());
873 }
874
875 #[test]
876 fn test_ep_strategy_basic() {
877 let geometries = vec![
878 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
879 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
880 ];
881
882 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
883 let config = Config::default().with_strategy(Strategy::ExtremePoint);
884 let packer = Packer3D::new(config);
885
886 let result = packer.solve(&geometries, &boundary).unwrap();
887
888 assert!(result.utilization > 0.0);
890 assert!(!result.placements.is_empty());
891 assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
892 }
893
894 #[test]
895 fn test_ep_strategy_all_placed() {
896 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
898
899 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
900 let config = Config::default().with_strategy(Strategy::ExtremePoint);
901 let packer = Packer3D::new(config);
902
903 let result = packer.solve(&geometries, &boundary).unwrap();
904
905 assert_eq!(result.placements.len(), 4);
907 assert!(result.unplaced.is_empty());
908 }
909
910 #[test]
911 fn test_ep_strategy_with_margin() {
912 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
913
914 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
915 let config = Config::default()
916 .with_strategy(Strategy::ExtremePoint)
917 .with_margin(5.0);
918 let packer = Packer3D::new(config);
919
920 let result = packer.solve(&geometries, &boundary).unwrap();
921
922 for p in &result.placements {
924 assert!(p.position[0] >= 4.9);
925 assert!(p.position[1] >= 4.9);
926 assert!(p.position[2] >= 4.9);
927 }
928 }
929
930 #[test]
931 fn test_ep_strategy_with_orientations() {
932 use crate::geometry::OrientationConstraint;
933
934 let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
936 .with_quantity(2)
937 .with_orientation(OrientationConstraint::Any)];
938
939 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
940 let config = Config::default().with_strategy(Strategy::ExtremePoint);
941 let packer = Packer3D::new(config);
942
943 let result = packer.solve(&geometries, &boundary).unwrap();
944
945 assert_eq!(result.placements.len(), 2);
947 }
948
949 #[test]
950 fn test_ep_strategy_perfect_fill_via_solve() {
951 let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
955 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
956 let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
957
958 let result = packer.solve(&geometries, &boundary).unwrap();
959
960 assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
961 assert!(result.unplaced.is_empty());
962 }
963
964 #[test]
965 fn test_ep_at_least_as_good_as_blf() {
966 let boundary = Boundary3D::new(85.0, 85.0, 80.0);
970 let cases: [(&str, f64, f64, f64, usize); 3] = [
971 ("big", 40.0, 40.0, 40.0, 4),
972 ("mid", 30.0, 20.0, 25.0, 6),
973 ("small", 15.0, 15.0, 30.0, 8),
974 ];
975 let geometries: Vec<Geometry3D> = cases
976 .iter()
977 .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
978 .collect();
979
980 let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
981 .solve(&geometries, &boundary)
982 .unwrap();
983 let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
984 .solve(&geometries, &boundary)
985 .unwrap();
986
987 assert!(
988 ep.placements.len() >= blf.placements.len(),
989 "EP placed {} but BLF placed {} — EP must not regress below BLF",
990 ep.placements.len(),
991 blf.placements.len()
992 );
993 }
994
995 fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
997 let geometries = vec![
998 Geometry3D::new("a", 20.0, 20.0, 20.0),
999 Geometry3D::new("b", 20.0, 20.0, 20.0),
1000 ];
1001 let mut result = SolveResult::new();
1002 result.placements.push(
1003 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1004 .with_rotation_index(0),
1005 );
1006 result.placements.push(
1008 Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1009 .with_rotation_index(0),
1010 );
1011 (geometries, result)
1012 }
1013
1014 #[test]
1015 fn test_gravity_removes_floating_box() {
1016 let (geometries, mut result) = floating_pair();
1017 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1018 let packer = Packer3D::default_config();
1019
1020 packer.enforce_support(&mut result, &geometries, &boundary);
1021
1022 let ids: Vec<&str> = result
1023 .placements
1024 .iter()
1025 .map(|p| p.geometry_id.as_str())
1026 .collect();
1027 assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1028 assert!(result.unplaced.iter().any(|id| id == "b"));
1029 }
1030
1031 #[test]
1032 fn test_no_constraint_keeps_floating_box() {
1033 let (geometries, result) = floating_pair();
1037 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1038 assert!(!boundary.has_gravity() && !boundary.has_stability());
1039 assert_eq!(result.placements.len(), 2);
1041 let _ = geometries;
1042 }
1043
1044 #[test]
1045 fn test_gravity_keeps_floor_boxes_with_margin() {
1046 let geometries = vec![
1051 Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1052 Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1053 ];
1054 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1055 let packer = Packer3D::new(
1056 Config::default()
1057 .with_margin(5.0)
1058 .with_strategy(Strategy::BottomLeftFill),
1059 );
1060
1061 let result = packer.solve(&geometries, &boundary).unwrap();
1062
1063 assert!(
1064 !result.placements.is_empty(),
1065 "margin>0 + gravity must not drop floor-resting boxes"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_stability_drops_undersupported_box() {
1071 let geometries = vec![
1075 Geometry3D::new("a", 40.0, 40.0, 20.0),
1076 Geometry3D::new("b", 40.0, 40.0, 20.0),
1077 ];
1078 let make = || {
1079 let mut r = SolveResult::new();
1080 r.placements.push(
1081 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1082 .with_rotation_index(0),
1083 );
1084 r.placements.push(
1086 Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1087 .with_rotation_index(0),
1088 );
1089 r
1090 };
1091 let packer = Packer3D::default_config();
1092
1093 let mut grav = make();
1094 packer.enforce_support(
1095 &mut grav,
1096 &geometries,
1097 &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1098 );
1099 assert_eq!(
1100 grav.placements.len(),
1101 2,
1102 "gravity keeps a box that touches below"
1103 );
1104
1105 let mut stab = make();
1106 packer.enforce_support(
1107 &mut stab,
1108 &geometries,
1109 &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1110 );
1111 assert!(
1112 stab.placements.iter().all(|p| p.geometry_id == "a"),
1113 "stability drops the under-supported box"
1114 );
1115 }
1116
1117 #[test]
1118 fn test_layer_packing_orientation_optimization() {
1119 use crate::geometry::OrientationConstraint;
1120
1121 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1124 .with_quantity(2)
1125 .with_orientation(OrientationConstraint::Any)];
1126
1127 let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1129 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1130 let packer = Packer3D::new(config);
1131
1132 let result = packer.solve(&geometries, &boundary).unwrap();
1133
1134 assert_eq!(
1136 result.placements.len(),
1137 2,
1138 "Both boxes should be placed by using rotation"
1139 );
1140 assert!(result.unplaced.is_empty());
1141
1142 for p in &result.placements {
1144 assert!(
1145 p.rotation_index.is_some(),
1146 "Placement should have rotation_index set"
1147 );
1148 }
1149 }
1150
1151 #[test]
1152 fn test_layer_packing_selects_best_orientation() {
1153 use crate::geometry::OrientationConstraint;
1154
1155 let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1160 .with_quantity(1)
1161 .with_orientation(OrientationConstraint::Any)];
1162
1163 let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1164 let packer = Packer3D::default_config();
1165
1166 let result = packer.solve(&geometries, &boundary).unwrap();
1167
1168 assert_eq!(result.placements.len(), 1);
1169 assert!(result.unplaced.is_empty());
1170 }
1171}