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, packing_fitness};
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::{
18 offered_names, Config, Dimension, ProgressCallback, ProgressInfo, Solver, Strategy,
19};
20use u_nesting_core::{Error, Placement, Result, SolveResult, StopReason};
21
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use u_nesting_core::timing::Timer;
25
26const GA_POPULATION: usize = 100;
30
31const GA_GENERATIONS: u32 = 500;
34
35const BRKGA_POPULATION: usize = 50;
38
39const BRKGA_GENERATIONS: u32 = 100;
42
43pub struct Packer3D {
45 config: Config,
46 cancelled: Arc<AtomicBool>,
47}
48
49impl Packer3D {
50 fn run_strategy(
56 &self,
57 geometries: &[Geometry3D],
58 boundary: &Boundary3D,
59 progress: Option<&ProgressCallback>,
60 ) -> Result<SolveResult<f64>> {
61 match self.config.strategy {
62 Strategy::BottomLeftFill => Ok(self.layer_fill(geometries, boundary, progress)),
63 Strategy::ExtremePoint => self.extreme_point(geometries, boundary, progress),
64 Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary, progress),
65 Strategy::Brkga => self.brkga(geometries, boundary, progress),
66 Strategy::SimulatedAnnealing => {
67 self.simulated_annealing(geometries, boundary, progress)
68 }
69 other => Err(Error::invalid_option(
70 "strategy",
71 format!(
72 "strategy \"{}\" is not available for 3D packing; it offers {}",
73 other.name(),
74 offered_names(Dimension::ThreeD)
75 ),
76 )),
77 }
78 }
79
80 pub fn cancel_handle(&self) -> Arc<AtomicBool> {
83 self.cancelled.clone()
84 }
85
86 pub fn new(config: Config) -> Self {
88 Self {
89 config,
90 cancelled: Arc::new(AtomicBool::new(false)),
91 }
92 }
93
94 pub fn default_config() -> Self {
96 Self::new(Config::default())
97 }
98
99 fn ga_config(&self) -> GaConfig {
103 let config = GaConfig::default()
104 .with_population_size(self.config.population_size.unwrap_or(GA_POPULATION))
105 .with_max_generations(self.config.max_generations.unwrap_or(GA_GENERATIONS))
106 .with_crossover_rate(self.config.crossover_rate)
107 .with_mutation_rate(self.config.mutation_rate)
108 .with_elite_count(self.config.elite_count);
109 match self.target_fitness() {
110 Some(target) => config.with_target_fitness(target),
111 None => config,
112 }
113 }
114
115 fn target_fitness(&self) -> Option<f64> {
118 self.config
119 .target_utilization
120 .map(|target| packing_fitness(1, 1, target))
121 }
122
123 fn brkga_config(&self) -> BrkgaConfig {
127 let config = BrkgaConfig::default()
128 .with_population_size(self.config.population_size.unwrap_or(BRKGA_POPULATION))
129 .with_max_generations(self.config.max_generations.unwrap_or(BRKGA_GENERATIONS))
130 .with_elite_fraction(0.2)
131 .with_mutant_fraction(0.15)
132 .with_elite_bias(0.7);
133 match self.target_fitness() {
134 Some(target) => config.with_target_fitness(target),
135 None => config,
136 }
137 }
138
139 pub fn validate(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<()> {
149 boundary.validate()?;
150 if geometries.is_empty() {
151 return Err(Error::empty_input("geometries"));
152 }
153 self.config.validate()?;
154 for geom in geometries {
155 geom.validate()?;
156 }
157 u_nesting_core::geometry::ensure_unique_ids(geometries)?;
158 if !self.config.strategy.is_offered(Dimension::ThreeD) {
159 return Err(Error::invalid_option(
160 "strategy",
161 format!(
162 "strategy \"{}\" is not available for 3D packing; it offers {}",
163 self.config.strategy.name(),
164 offered_names(Dimension::ThreeD)
165 ),
166 ));
167 }
168 Ok(())
169 }
170
171 pub fn validate_stability(
179 &self,
180 result: &SolveResult<f64>,
181 geometries: &[Geometry3D],
182 _boundary: &Boundary3D,
183 constraint: StabilityConstraint,
184 ) -> Result<StabilityReport> {
185 let placed_boxes = self.placements_to_boxes(result, geometries);
187 let analyzer = StabilityAnalyzer::new(constraint)?;
188 Ok(analyzer.analyze(&placed_boxes, 0.0))
189 }
190
191 pub fn validate_stability_physics(
195 &self,
196 result: &SolveResult<f64>,
197 geometries: &[Geometry3D],
198 boundary: &Boundary3D,
199 ) -> StabilityReport {
200 let placed_boxes = self.placements_to_boxes(result, geometries);
201 let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
202
203 let config = PhysicsConfig::default().with_max_time(2.0);
204 let simulator =
205 PhysicsSimulator::new(config).expect("the default physics settings are in range");
206 simulator.validate_stability(&placed_boxes, container, 0.0)
207 }
208
209 fn enforce_support(
220 &self,
221 result: &mut SolveResult<f64>,
222 geometries: &[Geometry3D],
223 boundary: &Boundary3D,
224 ) {
225 let constraint = if boundary.has_stability() {
228 StabilityConstraint::partial_base(0.7)
229 } else {
230 StabilityConstraint::partial_base(1e-6)
231 };
232
233 let floor_z = self.config.margin;
237 let analyzer = StabilityAnalyzer::new(constraint)
238 .expect("the support ratios used here, 0.7 and 1e-6, are in range");
239 while !result.placements.is_empty() {
240 let placed_boxes = self.placements_to_boxes(result, geometries);
241 let report = analyzer.analyze(&placed_boxes, floor_z);
242 if report.is_all_stable() {
243 break;
244 }
245 let drop: std::collections::HashSet<(String, usize)> = report
246 .unstable_boxes()
247 .iter()
248 .map(|r| (r.id.clone(), r.instance))
249 .collect();
250 result
251 .placements
252 .retain(|p| !drop.contains(&(p.geometry_id.clone(), p.instance)));
253 for (id, _) in drop {
254 result.unplaced.push(id);
255 }
256 }
257
258 result.deduplicate_unplaced();
259 let boxes = self.placements_to_boxes(result, geometries);
261 let placed_volume: f64 = boxes
262 .iter()
263 .map(|b| b.dimensions.x * b.dimensions.y * b.dimensions.z)
264 .sum();
265 let container_volume = boundary.measure();
266 result.utilization = if container_volume > 0.0 {
267 placed_volume / container_volume
268 } else {
269 0.0
270 };
271 }
272
273 fn placements_to_boxes(
275 &self,
276 result: &SolveResult<f64>,
277 geometries: &[Geometry3D],
278 ) -> Vec<PlacedBox> {
279 let geom_map: std::collections::HashMap<&str, &Geometry3D> =
280 geometries.iter().map(|g| (g.id().as_str(), g)).collect();
281
282 result
283 .placements
284 .iter()
285 .filter_map(|p| {
286 let geom = geom_map.get(p.geometry_id.as_str())?;
287 let ori_idx = p.rotation_index.unwrap_or(0);
288 let dims = geom.dimensions_for_orientation(ori_idx);
289
290 let mut placed = PlacedBox::new(
291 p.geometry_id.clone(),
292 p.instance,
293 Point3::new(p.position[0], p.position[1], p.position[2]),
294 dims,
295 );
296
297 if let Some(mass) = geom.mass() {
298 placed = placed.with_mass(mass);
299 }
300
301 Some(placed)
302 })
303 .collect()
304 }
305
306 fn layer_fill(
309 &self,
310 geometries: &[Geometry3D],
311 boundary: &Boundary3D,
312 callback: Option<&ProgressCallback>,
313 ) -> SolveResult<f64> {
314 let start = Timer::now();
315 let total: usize = geometries.iter().map(|g| g.quantity()).sum();
316 let report = |info: ProgressInfo| {
317 if let Some(callback) = callback {
318 callback(info);
319 }
320 };
321 report(
322 ProgressInfo::new()
323 .with_phase("Layer Packing")
324 .with_items(0, total)
325 .with_elapsed(0),
326 );
327
328 let mut stop = None;
329 let (fill, _stopped) = layer_fill_in_order(
330 geometries,
331 boundary,
332 &self.config,
333 || {
334 stop = self.stop_requested(&start);
335 stop.is_some()
336 },
337 |fill| {
338 report(
339 ProgressInfo::new()
340 .with_phase("Layer Packing")
341 .with_items(fill.placed(), total)
342 .with_utilization(fill.utilization())
343 .with_elapsed(start.elapsed_ms()),
344 )
345 },
346 );
347
348 let mut result = SolveResult::new();
349 result.unplaced = build_unplaced_list(&fill.placements, geometries);
350 result.boundaries_used = usize::from(!fill.placements.is_empty());
351 result.utilization = fill.utilization;
352 result.placements = fill.placements;
353 result.computation_time_ms = start.elapsed_ms();
354 let phase = match stop {
355 Some(StopReason::Cancelled) => "Cancelled",
356 Some(_) => "Time Limit Reached",
357 None => "Complete",
358 };
359 if let Some(stop) = stop {
360 result.stop_reason = stop;
361 }
362 report(
363 ProgressInfo::new()
364 .with_phase(phase)
365 .with_items(result.placements.len(), total)
366 .with_utilization(result.utilization)
367 .with_elapsed(result.computation_time_ms)
368 .finished(),
369 );
370 result
371 }
372
373 fn solve_reporting(
376 &self,
377 geometries: &[Geometry3D],
378 boundary: &Boundary3D,
379 progress: Option<&ProgressCallback>,
380 ) -> Result<SolveResult<f64>> {
381 self.validate(geometries, boundary)?;
382
383 self.cancelled.store(false, Ordering::Relaxed);
385 let start = Timer::now();
386
387 let mut result = self.run_strategy(geometries, boundary, progress)?;
388
389 result.record_unplaced(geometries);
391 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
394
395 if boundary.has_gravity() || boundary.has_stability() {
397 self.enforce_support(&mut result, geometries, boundary);
398 }
399 result.computation_time_ms = start.elapsed_ms();
402 Ok(result)
403 }
404
405 fn stop_requested(&self, start: &Timer) -> Option<StopReason> {
408 if self.cancelled.load(Ordering::Relaxed) {
409 Some(StopReason::Cancelled)
410 } else if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms {
411 Some(StopReason::TimeLimit)
412 } else {
413 None
414 }
415 }
416
417 fn search_time_limit(&self, started: &Timer) -> Option<std::time::Duration> {
420 (self.config.time_limit_ms > 0).then(|| {
421 std::time::Duration::from_millis(
422 self.config
423 .time_limit_ms
424 .saturating_sub(started.elapsed_ms())
425 .max(1),
426 )
427 })
428 }
429
430 fn not_worse_than_fills(
440 &self,
441 search: SolveResult<f64>,
442 geometries: &[Geometry3D],
443 boundary: &Boundary3D,
444 layer_fill: SolveResult<f64>,
445 ) -> SolveResult<f64> {
446 let supported = |mut r: SolveResult<f64>| {
447 if boundary.has_gravity() || boundary.has_stability() {
448 self.enforce_support(&mut r, geometries, boundary);
449 }
450 r
451 };
452 let fills = [
453 Some(layer_fill),
454 self.extreme_point(geometries, boundary, None).ok(),
455 ]
456 .into_iter()
457 .flatten()
458 .map(supported);
459 supported(search).floored_at(fills, |a, b| {
460 let (pa, pb) = (a.placements.len(), b.placements.len());
461 pa > pb || (pa == pb && a.utilization > b.utilization + 1e-9)
462 })
463 }
464
465 fn genetic_algorithm(
470 &self,
471 geometries: &[Geometry3D],
472 boundary: &Boundary3D,
473 progress: Option<&ProgressCallback>,
474 ) -> Result<SolveResult<f64>> {
475 let started = Timer::now();
476 let fill = self.layer_fill(geometries, boundary, progress);
477 let mut ga_config = self.ga_config();
478 ga_config.time_limit = self.search_time_limit(&started);
479 let result = run_ga_packing(
480 geometries,
481 boundary,
482 &self.config,
483 ga_config,
484 self.cancelled.clone(),
485 Some(&fill.placements),
486 progress,
487 )?;
488 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
489 }
490
491 fn brkga(
496 &self,
497 geometries: &[Geometry3D],
498 boundary: &Boundary3D,
499 progress: Option<&ProgressCallback>,
500 ) -> Result<SolveResult<f64>> {
501 let started = Timer::now();
502 let fill = self.layer_fill(geometries, boundary, progress);
503 let mut brkga_config = self.brkga_config();
504 brkga_config.time_limit = self.search_time_limit(&started);
505 let result = run_brkga_packing(
506 geometries,
507 boundary,
508 &self.config,
509 brkga_config,
510 self.cancelled.clone(),
511 Some(&fill.placements),
512 progress,
513 )?;
514 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
515 }
516
517 fn simulated_annealing(
522 &self,
523 geometries: &[Geometry3D],
524 boundary: &Boundary3D,
525 progress: Option<&ProgressCallback>,
526 ) -> Result<SolveResult<f64>> {
527 let started = Timer::now();
528 let fill = self.layer_fill(geometries, boundary, progress);
529 let mut sa_config = SaConfig::default()
530 .with_initial_temp(0.5)
540 .with_final_temp(0.01)
541 .with_cooling_rate(0.98)
542 .with_iterations_per_temp(50)
543 .with_max_iterations(self.config.max_iterations.unwrap_or(10000));
544 sa_config.time_limit = self.search_time_limit(&started);
545 sa_config.target_fitness = self.target_fitness();
546
547 let result = run_sa_packing(
548 geometries,
549 boundary,
550 &self.config,
551 sa_config,
552 self.cancelled.clone(),
553 Some(&fill.placements),
554 progress,
555 )?;
556 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
557 }
558
559 fn extreme_point(
564 &self,
565 geometries: &[Geometry3D],
566 boundary: &Boundary3D,
567 progress: Option<&ProgressCallback>,
568 ) -> Result<SolveResult<f64>> {
569 let start = Timer::now();
570 let total: usize = geometries.iter().map(|g| g.quantity()).sum();
571 let report = |info: ProgressInfo| {
572 if let Some(callback) = progress {
573 callback(info.with_phase("Extreme Point"));
574 }
575 };
576
577 let mut stop = None;
578 let (ep_placements, utilization) = run_ep_packing(
579 geometries,
580 boundary,
581 self.config.margin,
582 self.config.spacing,
583 boundary.max_mass(),
584 || {
585 report(
587 ProgressInfo::new()
588 .with_items(0, total)
589 .with_elapsed(start.elapsed_ms()),
590 );
591 stop = self.stop_requested(&start);
592 stop.is_some()
593 },
594 );
595
596 let mut placements = Vec::new();
601 for (id, instance, position, orientation) in ep_placements {
602 let placement = Placement::new_3d(
603 id, instance, position.x, position.y, position.z, 0.0, 0.0, 0.0, )
607 .with_rotation_index(orientation);
608 placements.push(placement);
609 }
610
611 let unplaced = build_unplaced_list(&placements, geometries);
612
613 let mut result = SolveResult::new();
614 result.boundaries_used = usize::from(!placements.is_empty());
615 result.placements = placements;
616 result.utilization = utilization;
617 if let Some(stop) = stop {
618 result.stop_reason = stop;
619 }
620 result.unplaced = unplaced;
621 result.computation_time_ms = start.elapsed_ms();
622 result.strategy = Some("ExtremePoint".to_string());
623 report(
624 ProgressInfo::new()
625 .with_items(result.placements.len(), total)
626 .with_utilization(result.utilization)
627 .with_elapsed(result.computation_time_ms)
628 .finished(),
629 );
630
631 Ok(result)
632 }
633}
634
635impl Solver for Packer3D {
636 type Geometry = Geometry3D;
637 type Boundary = Boundary3D;
638 type Scalar = f64;
639
640 fn solve(
641 &self,
642 geometries: &[Self::Geometry],
643 boundary: &Self::Boundary,
644 ) -> Result<SolveResult<f64>> {
645 self.solve_reporting(geometries, boundary, None)
646 }
647
648 fn solve_with_progress(
649 &self,
650 geometries: &[Self::Geometry],
651 boundary: &Self::Boundary,
652 callback: ProgressCallback,
653 ) -> Result<SolveResult<f64>> {
654 self.solve_reporting(geometries, boundary, Some(&callback))
655 }
656
657 fn cancel(&self) {
658 self.cancelled.store(true, Ordering::Relaxed);
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
668 let geometries = [
669 Geometry3D::new("B", 10.0, 10.0, 10.0),
670 Geometry3D::new("B", 50.0, 50.0, 50.0),
671 ];
672 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
673 let packer = Packer3D::default_config();
674 let errors = [
675 packer.solve(&geometries, &boundary).expect_err("solve"),
676 packer
677 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
678 .expect_err("solve_with_progress"),
679 ];
680 for err in errors {
681 let msg = err.to_string();
682 assert!(msg.contains("'B'"), "{msg}");
683 assert!(msg.contains("positions 0 and 1"), "{msg}");
684 }
685 }
686
687 #[test]
692 fn every_entry_point_judges_the_whole_request() {
693 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
694 let packer = Packer3D::default_config();
695 let empty = Error::EmptyInput {
696 parameter: "geometries".into(),
697 };
698 assert_eq!(packer.solve(&[], &boundary).expect_err("solve"), empty);
699 assert_eq!(
700 packer
701 .solve_with_progress(&[], &boundary, Box::new(|_| {}))
702 .expect_err("solve_with_progress"),
703 empty
704 );
705
706 let boxes = [Geometry3D::new("B", 10.0, 10.0, 10.0)];
707 let bad_config = Packer3D::new(Config::default().with_spacing(-1.0));
708 for err in [
709 bad_config.solve(&boxes, &boundary).expect_err("solve"),
710 bad_config
711 .solve_with_progress(&boxes, &boundary, Box::new(|_| {}))
712 .expect_err("solve_with_progress"),
713 ] {
714 assert_eq!(err.code(), "parameter_out_of_range", "{err}");
715 }
716
717 let flat = [Geometry3D::new("flat", 10.0, 0.0, 10.0)];
718 for strategy in [Strategy::ExtremePoint, Strategy::GeneticAlgorithm] {
719 let packer = Packer3D::new(Config::default().with_strategy(strategy));
720 match packer.solve(&flat, &boundary) {
721 Err(Error::OutOfRange { parameter, id, .. }) => {
722 assert_eq!(parameter, "dimensions[1]");
723 assert_eq!(id.as_deref(), Some("flat"));
724 }
725 other => panic!("{strategy:?}: {other:?}"),
726 }
727 }
728 }
729
730 #[test]
734 fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
735 let unset = Packer3D::default_config();
736 assert_eq!(unset.ga_config().population_size, 100);
737 assert_eq!(unset.ga_config().max_generations, 500);
738 assert_eq!(unset.brkga_config().population_size, 50);
739 assert_eq!(unset.brkga_config().max_generations, 100);
740
741 let set = Packer3D::new(
742 Config::default()
743 .with_population_size(12)
744 .with_max_generations(3),
745 );
746 assert_eq!(set.ga_config().population_size, 12);
747 assert_eq!(set.ga_config().max_generations, 3);
748 assert_eq!(set.brkga_config().population_size, 12);
749 assert_eq!(set.brkga_config().max_generations, 3);
750 }
751
752 #[test]
753 fn test_simple_packing() {
754 let geometries = vec![
755 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
756 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
757 ];
758
759 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
760 let packer = Packer3D::default_config();
761
762 let result = packer.solve(&geometries, &boundary).unwrap();
763
764 assert!(result.utilization > 0.0);
765 assert!(result.placements.len() <= 5);
766 }
767
768 #[test]
769 fn test_mass_constraint() {
770 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
771 .with_quantity(10)
772 .with_mass(100.0)];
773
774 let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
775
776 let packer = Packer3D::default_config();
777 let result = packer.solve(&geometries, &boundary).unwrap();
778
779 assert!(result.placements.len() <= 3);
781 }
782
783 #[test]
784 fn test_placement_within_bounds() {
785 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
786
787 let boundary = Boundary3D::new(50.0, 50.0, 50.0);
788 let config = Config::default().with_margin(5.0).with_spacing(2.0);
789 let packer = Packer3D::new(config);
790
791 let result = packer.solve(&geometries, &boundary).unwrap();
792
793 assert_eq!(result.placements.len(), 4);
795 assert!(result.unplaced.is_empty());
796
797 for p in &result.placements {
799 assert!(p.position[0] >= 5.0);
800 assert!(p.position[1] >= 5.0);
801 assert!(p.position[2] >= 5.0);
802 }
803 }
804
805 #[test]
806 fn test_ga_strategy_basic() {
807 let geometries = vec![
808 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
809 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
810 ];
811
812 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
813 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
814 let packer = Packer3D::new(config);
815
816 let result = packer.solve(&geometries, &boundary).unwrap();
817
818 assert!(result.utilization > 0.0);
820 assert!(!result.placements.is_empty());
821 }
822
823 #[test]
824 fn test_ga_strategy_all_placed() {
825 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
827
828 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
829 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
830 let packer = Packer3D::new(config);
831
832 let result = packer.solve(&geometries, &boundary).unwrap();
833
834 assert_eq!(result.placements.len(), 4);
836 assert!(result.unplaced.is_empty());
837 }
838
839 #[test]
840 #[cfg(feature = "serde")]
841 fn test_3d_response_orientation_in_bounds() {
842 use crate::build_pack3d_response;
843 use crate::geometry::OrientationConstraint;
844
845 let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
851 let boundary = Boundary3D::new(bw, bd, bh);
852
853 for strategy in [
854 Strategy::ExtremePoint,
855 Strategy::GeneticAlgorithm,
856 Strategy::Brkga,
857 ] {
858 let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
859 .with_quantity(3)
860 .with_orientation(OrientationConstraint::Any)];
861 let config = Config::default().with_strategy(strategy);
862 let packer = Packer3D::new(config);
863 let result = packer.solve(&geometries, &boundary).unwrap();
864 let response = build_pack3d_response(&result, &geometries);
865
866 if strategy == Strategy::ExtremePoint {
868 assert!(
869 !response.placements.is_empty(),
870 "EP should place the tall box by rotating it into the flat container"
871 );
872 }
873
874 let base = geometries[0].dimensions_for_orientation(0); for p in &response.placements {
876 let axes: Vec<usize> = p
877 .orientation
878 .chars()
879 .map(|c| match c {
880 'x' => 0,
881 'y' => 1,
882 'z' => 2,
883 _ => panic!("unexpected orientation label '{}'", p.orientation),
884 })
885 .collect();
886 let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
887 let e = 1e-6;
888 assert!(
889 p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
890 "{:?} {}#{} out of bounds for reported orientation '{}': \
891 pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
892 strategy,
893 p.geometry_id,
894 p.instance,
895 p.orientation,
896 p.x,
897 p.y,
898 p.z,
899 );
900 }
901 }
902 }
903
904 #[test]
905 fn test_ga_strategy_with_orientations() {
906 use crate::geometry::OrientationConstraint;
907
908 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
910 .with_quantity(2)
911 .with_orientation(OrientationConstraint::Any)];
912
913 let boundary = Boundary3D::new(60.0, 60.0, 60.0);
915 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
916 let packer = Packer3D::new(config);
917
918 let result = packer.solve(&geometries, &boundary).unwrap();
919
920 assert_eq!(result.placements.len(), 2);
922 }
923
924 #[test]
925 fn test_brkga_strategy_basic() {
926 let geometries = vec![
927 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
928 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
929 ];
930
931 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
932 let config = Config::default().with_strategy(Strategy::Brkga);
933 let packer = Packer3D::new(config);
934
935 let result = packer.solve(&geometries, &boundary).unwrap();
936
937 assert!(result.utilization > 0.0);
939 assert!(!result.placements.is_empty());
940 assert_eq!(result.strategy, Some("BRKGA".to_string()));
941 }
942
943 #[test]
944 fn test_brkga_strategy_all_placed() {
945 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
947
948 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
949 let config = Config::default().with_strategy(Strategy::Brkga);
950 let packer = Packer3D::new(config);
951
952 let result = packer.solve(&geometries, &boundary).unwrap();
953
954 assert_eq!(result.placements.len(), 4);
956 assert!(result.unplaced.is_empty());
957 }
958
959 #[test]
960 fn test_ep_strategy_basic() {
961 let geometries = vec![
962 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
963 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
964 ];
965
966 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
967 let config = Config::default().with_strategy(Strategy::ExtremePoint);
968 let packer = Packer3D::new(config);
969
970 let result = packer.solve(&geometries, &boundary).unwrap();
971
972 assert!(result.utilization > 0.0);
974 assert!(!result.placements.is_empty());
975 assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
976 }
977
978 #[test]
979 fn test_ep_strategy_all_placed() {
980 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
982
983 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
984 let config = Config::default().with_strategy(Strategy::ExtremePoint);
985 let packer = Packer3D::new(config);
986
987 let result = packer.solve(&geometries, &boundary).unwrap();
988
989 assert_eq!(result.placements.len(), 4);
991 assert!(result.unplaced.is_empty());
992 }
993
994 #[test]
995 fn test_ep_strategy_with_margin() {
996 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
997
998 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
999 let config = Config::default()
1000 .with_strategy(Strategy::ExtremePoint)
1001 .with_margin(5.0);
1002 let packer = Packer3D::new(config);
1003
1004 let result = packer.solve(&geometries, &boundary).unwrap();
1005
1006 for p in &result.placements {
1008 assert!(p.position[0] >= 4.9);
1009 assert!(p.position[1] >= 4.9);
1010 assert!(p.position[2] >= 4.9);
1011 }
1012 }
1013
1014 #[test]
1015 fn test_ep_strategy_with_orientations() {
1016 use crate::geometry::OrientationConstraint;
1017
1018 let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
1020 .with_quantity(2)
1021 .with_orientation(OrientationConstraint::Any)];
1022
1023 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1024 let config = Config::default().with_strategy(Strategy::ExtremePoint);
1025 let packer = Packer3D::new(config);
1026
1027 let result = packer.solve(&geometries, &boundary).unwrap();
1028
1029 assert_eq!(result.placements.len(), 2);
1031 }
1032
1033 #[test]
1034 fn test_ep_strategy_perfect_fill_via_solve() {
1035 let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
1039 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1040 let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
1041
1042 let result = packer.solve(&geometries, &boundary).unwrap();
1043
1044 assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
1045 assert!(result.unplaced.is_empty());
1046 }
1047
1048 #[test]
1049 fn test_ep_at_least_as_good_as_blf() {
1050 let boundary = Boundary3D::new(85.0, 85.0, 80.0);
1054 let cases: [(&str, f64, f64, f64, usize); 3] = [
1055 ("big", 40.0, 40.0, 40.0, 4),
1056 ("mid", 30.0, 20.0, 25.0, 6),
1057 ("small", 15.0, 15.0, 30.0, 8),
1058 ];
1059 let geometries: Vec<Geometry3D> = cases
1060 .iter()
1061 .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
1062 .collect();
1063
1064 let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
1065 .solve(&geometries, &boundary)
1066 .unwrap();
1067 let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
1068 .solve(&geometries, &boundary)
1069 .unwrap();
1070
1071 assert!(
1072 ep.placements.len() >= blf.placements.len(),
1073 "EP placed {} but BLF placed {} — EP must not regress below BLF",
1074 ep.placements.len(),
1075 blf.placements.len()
1076 );
1077 }
1078
1079 fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
1081 let geometries = vec![
1082 Geometry3D::new("a", 20.0, 20.0, 20.0),
1083 Geometry3D::new("b", 20.0, 20.0, 20.0),
1084 ];
1085 let mut result = SolveResult::new();
1086 result.placements.push(
1087 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1088 .with_rotation_index(0),
1089 );
1090 result.placements.push(
1092 Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1093 .with_rotation_index(0),
1094 );
1095 (geometries, result)
1096 }
1097
1098 #[test]
1099 fn test_gravity_removes_floating_box() {
1100 let (geometries, mut result) = floating_pair();
1101 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1102 let packer = Packer3D::default_config();
1103
1104 packer.enforce_support(&mut result, &geometries, &boundary);
1105
1106 let ids: Vec<&str> = result
1107 .placements
1108 .iter()
1109 .map(|p| p.geometry_id.as_str())
1110 .collect();
1111 assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1112 assert!(result.unplaced.iter().any(|id| id == "b"));
1113 }
1114
1115 #[test]
1116 fn test_no_constraint_keeps_floating_box() {
1117 let (geometries, result) = floating_pair();
1121 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1122 assert!(!boundary.has_gravity() && !boundary.has_stability());
1123 assert_eq!(result.placements.len(), 2);
1125 let _ = geometries;
1126 }
1127
1128 #[test]
1129 fn test_gravity_keeps_floor_boxes_with_margin() {
1130 let geometries = vec![
1135 Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1136 Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1137 ];
1138 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1139 let packer = Packer3D::new(
1140 Config::default()
1141 .with_margin(5.0)
1142 .with_strategy(Strategy::BottomLeftFill),
1143 );
1144
1145 let result = packer.solve(&geometries, &boundary).unwrap();
1146
1147 assert!(
1148 !result.placements.is_empty(),
1149 "margin>0 + gravity must not drop floor-resting boxes"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_stability_drops_undersupported_box() {
1155 let geometries = vec![
1159 Geometry3D::new("a", 40.0, 40.0, 20.0),
1160 Geometry3D::new("b", 40.0, 40.0, 20.0),
1161 ];
1162 let make = || {
1163 let mut r = SolveResult::new();
1164 r.placements.push(
1165 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1166 .with_rotation_index(0),
1167 );
1168 r.placements.push(
1170 Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1171 .with_rotation_index(0),
1172 );
1173 r
1174 };
1175 let packer = Packer3D::default_config();
1176
1177 let mut grav = make();
1178 packer.enforce_support(
1179 &mut grav,
1180 &geometries,
1181 &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1182 );
1183 assert_eq!(
1184 grav.placements.len(),
1185 2,
1186 "gravity keeps a box that touches below"
1187 );
1188
1189 let mut stab = make();
1190 packer.enforce_support(
1191 &mut stab,
1192 &geometries,
1193 &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1194 );
1195 assert!(
1196 stab.placements.iter().all(|p| p.geometry_id == "a"),
1197 "stability drops the under-supported box"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_layer_packing_orientation_optimization() {
1203 use crate::geometry::OrientationConstraint;
1204
1205 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1208 .with_quantity(2)
1209 .with_orientation(OrientationConstraint::Any)];
1210
1211 let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1213 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1214 let packer = Packer3D::new(config);
1215
1216 let result = packer.solve(&geometries, &boundary).unwrap();
1217
1218 assert_eq!(
1220 result.placements.len(),
1221 2,
1222 "Both boxes should be placed by using rotation"
1223 );
1224 assert!(result.unplaced.is_empty());
1225
1226 for p in &result.placements {
1228 assert!(
1229 p.rotation_index.is_some(),
1230 "Placement should have rotation_index set"
1231 );
1232 }
1233 }
1234
1235 #[test]
1236 fn test_layer_packing_selects_best_orientation() {
1237 use crate::geometry::OrientationConstraint;
1238
1239 let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1244 .with_quantity(1)
1245 .with_orientation(OrientationConstraint::Any)];
1246
1247 let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1248 let packer = Packer3D::default_config();
1249
1250 let result = packer.solve(&geometries, &boundary).unwrap();
1251
1252 assert_eq!(result.placements.len(), 1);
1253 assert!(result.unplaced.is_empty());
1254 }
1255}