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)
536 .with_final_temp(0.01)
537 .with_cooling_rate(0.98)
538 .with_iterations_per_temp(50)
539 .with_max_iterations(self.config.max_iterations.unwrap_or(10000));
540 sa_config.time_limit = self.search_time_limit(&started);
541 sa_config.target_fitness = self.target_fitness();
542
543 let result = run_sa_packing(
544 geometries,
545 boundary,
546 &self.config,
547 sa_config,
548 self.cancelled.clone(),
549 Some(&fill.placements),
550 progress,
551 )?;
552 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
553 }
554
555 fn extreme_point(
560 &self,
561 geometries: &[Geometry3D],
562 boundary: &Boundary3D,
563 progress: Option<&ProgressCallback>,
564 ) -> Result<SolveResult<f64>> {
565 let start = Timer::now();
566 let total: usize = geometries.iter().map(|g| g.quantity()).sum();
567 let report = |info: ProgressInfo| {
568 if let Some(callback) = progress {
569 callback(info.with_phase("Extreme Point"));
570 }
571 };
572
573 let mut stop = None;
574 let (ep_placements, utilization) = run_ep_packing(
575 geometries,
576 boundary,
577 self.config.margin,
578 self.config.spacing,
579 boundary.max_mass(),
580 || {
581 report(
583 ProgressInfo::new()
584 .with_items(0, total)
585 .with_elapsed(start.elapsed_ms()),
586 );
587 stop = self.stop_requested(&start);
588 stop.is_some()
589 },
590 );
591
592 let mut placements = Vec::new();
597 for (id, instance, position, orientation) in ep_placements {
598 let placement = Placement::new_3d(
599 id, instance, position.x, position.y, position.z, 0.0, 0.0, 0.0, )
603 .with_rotation_index(orientation);
604 placements.push(placement);
605 }
606
607 let unplaced = build_unplaced_list(&placements, geometries);
608
609 let mut result = SolveResult::new();
610 result.boundaries_used = usize::from(!placements.is_empty());
611 result.placements = placements;
612 result.utilization = utilization;
613 if let Some(stop) = stop {
614 result.stop_reason = stop;
615 }
616 result.unplaced = unplaced;
617 result.computation_time_ms = start.elapsed_ms();
618 result.strategy = Some("ExtremePoint".to_string());
619 report(
620 ProgressInfo::new()
621 .with_items(result.placements.len(), total)
622 .with_utilization(result.utilization)
623 .with_elapsed(result.computation_time_ms)
624 .finished(),
625 );
626
627 Ok(result)
628 }
629}
630
631impl Solver for Packer3D {
632 type Geometry = Geometry3D;
633 type Boundary = Boundary3D;
634 type Scalar = f64;
635
636 fn solve(
637 &self,
638 geometries: &[Self::Geometry],
639 boundary: &Self::Boundary,
640 ) -> Result<SolveResult<f64>> {
641 self.solve_reporting(geometries, boundary, None)
642 }
643
644 fn solve_with_progress(
645 &self,
646 geometries: &[Self::Geometry],
647 boundary: &Self::Boundary,
648 callback: ProgressCallback,
649 ) -> Result<SolveResult<f64>> {
650 self.solve_reporting(geometries, boundary, Some(&callback))
651 }
652
653 fn cancel(&self) {
654 self.cancelled.store(true, Ordering::Relaxed);
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
664 let geometries = [
665 Geometry3D::new("B", 10.0, 10.0, 10.0),
666 Geometry3D::new("B", 50.0, 50.0, 50.0),
667 ];
668 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
669 let packer = Packer3D::default_config();
670 let errors = [
671 packer.solve(&geometries, &boundary).expect_err("solve"),
672 packer
673 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
674 .expect_err("solve_with_progress"),
675 ];
676 for err in errors {
677 let msg = err.to_string();
678 assert!(msg.contains("'B'"), "{msg}");
679 assert!(msg.contains("positions 0 and 1"), "{msg}");
680 }
681 }
682
683 #[test]
688 fn every_entry_point_judges_the_whole_request() {
689 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
690 let packer = Packer3D::default_config();
691 let empty = Error::EmptyInput {
692 parameter: "geometries".into(),
693 };
694 assert_eq!(packer.solve(&[], &boundary).expect_err("solve"), empty);
695 assert_eq!(
696 packer
697 .solve_with_progress(&[], &boundary, Box::new(|_| {}))
698 .expect_err("solve_with_progress"),
699 empty
700 );
701
702 let boxes = [Geometry3D::new("B", 10.0, 10.0, 10.0)];
703 let bad_config = Packer3D::new(Config::default().with_spacing(-1.0));
704 for err in [
705 bad_config.solve(&boxes, &boundary).expect_err("solve"),
706 bad_config
707 .solve_with_progress(&boxes, &boundary, Box::new(|_| {}))
708 .expect_err("solve_with_progress"),
709 ] {
710 assert_eq!(err.code(), "parameter_out_of_range", "{err}");
711 }
712
713 let flat = [Geometry3D::new("flat", 10.0, 0.0, 10.0)];
714 for strategy in [Strategy::ExtremePoint, Strategy::GeneticAlgorithm] {
715 let packer = Packer3D::new(Config::default().with_strategy(strategy));
716 match packer.solve(&flat, &boundary) {
717 Err(Error::OutOfRange { parameter, id, .. }) => {
718 assert_eq!(parameter, "dimensions[1]");
719 assert_eq!(id.as_deref(), Some("flat"));
720 }
721 other => panic!("{strategy:?}: {other:?}"),
722 }
723 }
724 }
725
726 #[test]
730 fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
731 let unset = Packer3D::default_config();
732 assert_eq!(unset.ga_config().population_size, 100);
733 assert_eq!(unset.ga_config().max_generations, 500);
734 assert_eq!(unset.brkga_config().population_size, 50);
735 assert_eq!(unset.brkga_config().max_generations, 100);
736
737 let set = Packer3D::new(
738 Config::default()
739 .with_population_size(12)
740 .with_max_generations(3),
741 );
742 assert_eq!(set.ga_config().population_size, 12);
743 assert_eq!(set.ga_config().max_generations, 3);
744 assert_eq!(set.brkga_config().population_size, 12);
745 assert_eq!(set.brkga_config().max_generations, 3);
746 }
747
748 #[test]
749 fn test_simple_packing() {
750 let geometries = vec![
751 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
752 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
753 ];
754
755 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
756 let packer = Packer3D::default_config();
757
758 let result = packer.solve(&geometries, &boundary).unwrap();
759
760 assert!(result.utilization > 0.0);
761 assert!(result.placements.len() <= 5);
762 }
763
764 #[test]
765 fn test_mass_constraint() {
766 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
767 .with_quantity(10)
768 .with_mass(100.0)];
769
770 let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
771
772 let packer = Packer3D::default_config();
773 let result = packer.solve(&geometries, &boundary).unwrap();
774
775 assert!(result.placements.len() <= 3);
777 }
778
779 #[test]
780 fn test_placement_within_bounds() {
781 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
782
783 let boundary = Boundary3D::new(50.0, 50.0, 50.0);
784 let config = Config::default().with_margin(5.0).with_spacing(2.0);
785 let packer = Packer3D::new(config);
786
787 let result = packer.solve(&geometries, &boundary).unwrap();
788
789 assert_eq!(result.placements.len(), 4);
791 assert!(result.unplaced.is_empty());
792
793 for p in &result.placements {
795 assert!(p.position[0] >= 5.0);
796 assert!(p.position[1] >= 5.0);
797 assert!(p.position[2] >= 5.0);
798 }
799 }
800
801 #[test]
802 fn test_ga_strategy_basic() {
803 let geometries = vec![
804 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
805 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
806 ];
807
808 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
809 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
810 let packer = Packer3D::new(config);
811
812 let result = packer.solve(&geometries, &boundary).unwrap();
813
814 assert!(result.utilization > 0.0);
816 assert!(!result.placements.is_empty());
817 }
818
819 #[test]
820 fn test_ga_strategy_all_placed() {
821 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
823
824 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
825 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
826 let packer = Packer3D::new(config);
827
828 let result = packer.solve(&geometries, &boundary).unwrap();
829
830 assert_eq!(result.placements.len(), 4);
832 assert!(result.unplaced.is_empty());
833 }
834
835 #[test]
836 #[cfg(feature = "serde")]
837 fn test_3d_response_orientation_in_bounds() {
838 use crate::build_pack3d_response;
839 use crate::geometry::OrientationConstraint;
840
841 let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
847 let boundary = Boundary3D::new(bw, bd, bh);
848
849 for strategy in [
850 Strategy::ExtremePoint,
851 Strategy::GeneticAlgorithm,
852 Strategy::Brkga,
853 ] {
854 let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
855 .with_quantity(3)
856 .with_orientation(OrientationConstraint::Any)];
857 let config = Config::default().with_strategy(strategy);
858 let packer = Packer3D::new(config);
859 let result = packer.solve(&geometries, &boundary).unwrap();
860 let response = build_pack3d_response(&result, &geometries);
861
862 if strategy == Strategy::ExtremePoint {
864 assert!(
865 !response.placements.is_empty(),
866 "EP should place the tall box by rotating it into the flat container"
867 );
868 }
869
870 let base = geometries[0].dimensions_for_orientation(0); for p in &response.placements {
872 let axes: Vec<usize> = p
873 .orientation
874 .chars()
875 .map(|c| match c {
876 'x' => 0,
877 'y' => 1,
878 'z' => 2,
879 _ => panic!("unexpected orientation label '{}'", p.orientation),
880 })
881 .collect();
882 let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
883 let e = 1e-6;
884 assert!(
885 p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
886 "{:?} {}#{} out of bounds for reported orientation '{}': \
887 pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
888 strategy,
889 p.geometry_id,
890 p.instance,
891 p.orientation,
892 p.x,
893 p.y,
894 p.z,
895 );
896 }
897 }
898 }
899
900 #[test]
901 fn test_ga_strategy_with_orientations() {
902 use crate::geometry::OrientationConstraint;
903
904 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
906 .with_quantity(2)
907 .with_orientation(OrientationConstraint::Any)];
908
909 let boundary = Boundary3D::new(60.0, 60.0, 60.0);
911 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
912 let packer = Packer3D::new(config);
913
914 let result = packer.solve(&geometries, &boundary).unwrap();
915
916 assert_eq!(result.placements.len(), 2);
918 }
919
920 #[test]
921 fn test_brkga_strategy_basic() {
922 let geometries = vec![
923 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
924 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
925 ];
926
927 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
928 let config = Config::default().with_strategy(Strategy::Brkga);
929 let packer = Packer3D::new(config);
930
931 let result = packer.solve(&geometries, &boundary).unwrap();
932
933 assert!(result.utilization > 0.0);
935 assert!(!result.placements.is_empty());
936 assert_eq!(result.strategy, Some("BRKGA".to_string()));
937 }
938
939 #[test]
940 fn test_brkga_strategy_all_placed() {
941 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
943
944 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
945 let config = Config::default().with_strategy(Strategy::Brkga);
946 let packer = Packer3D::new(config);
947
948 let result = packer.solve(&geometries, &boundary).unwrap();
949
950 assert_eq!(result.placements.len(), 4);
952 assert!(result.unplaced.is_empty());
953 }
954
955 #[test]
956 fn test_ep_strategy_basic() {
957 let geometries = vec![
958 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
959 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
960 ];
961
962 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
963 let config = Config::default().with_strategy(Strategy::ExtremePoint);
964 let packer = Packer3D::new(config);
965
966 let result = packer.solve(&geometries, &boundary).unwrap();
967
968 assert!(result.utilization > 0.0);
970 assert!(!result.placements.is_empty());
971 assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
972 }
973
974 #[test]
975 fn test_ep_strategy_all_placed() {
976 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
978
979 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
980 let config = Config::default().with_strategy(Strategy::ExtremePoint);
981 let packer = Packer3D::new(config);
982
983 let result = packer.solve(&geometries, &boundary).unwrap();
984
985 assert_eq!(result.placements.len(), 4);
987 assert!(result.unplaced.is_empty());
988 }
989
990 #[test]
991 fn test_ep_strategy_with_margin() {
992 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
993
994 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
995 let config = Config::default()
996 .with_strategy(Strategy::ExtremePoint)
997 .with_margin(5.0);
998 let packer = Packer3D::new(config);
999
1000 let result = packer.solve(&geometries, &boundary).unwrap();
1001
1002 for p in &result.placements {
1004 assert!(p.position[0] >= 4.9);
1005 assert!(p.position[1] >= 4.9);
1006 assert!(p.position[2] >= 4.9);
1007 }
1008 }
1009
1010 #[test]
1011 fn test_ep_strategy_with_orientations() {
1012 use crate::geometry::OrientationConstraint;
1013
1014 let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
1016 .with_quantity(2)
1017 .with_orientation(OrientationConstraint::Any)];
1018
1019 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1020 let config = Config::default().with_strategy(Strategy::ExtremePoint);
1021 let packer = Packer3D::new(config);
1022
1023 let result = packer.solve(&geometries, &boundary).unwrap();
1024
1025 assert_eq!(result.placements.len(), 2);
1027 }
1028
1029 #[test]
1030 fn test_ep_strategy_perfect_fill_via_solve() {
1031 let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
1035 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1036 let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
1037
1038 let result = packer.solve(&geometries, &boundary).unwrap();
1039
1040 assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
1041 assert!(result.unplaced.is_empty());
1042 }
1043
1044 #[test]
1045 fn test_ep_at_least_as_good_as_blf() {
1046 let boundary = Boundary3D::new(85.0, 85.0, 80.0);
1050 let cases: [(&str, f64, f64, f64, usize); 3] = [
1051 ("big", 40.0, 40.0, 40.0, 4),
1052 ("mid", 30.0, 20.0, 25.0, 6),
1053 ("small", 15.0, 15.0, 30.0, 8),
1054 ];
1055 let geometries: Vec<Geometry3D> = cases
1056 .iter()
1057 .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
1058 .collect();
1059
1060 let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
1061 .solve(&geometries, &boundary)
1062 .unwrap();
1063 let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
1064 .solve(&geometries, &boundary)
1065 .unwrap();
1066
1067 assert!(
1068 ep.placements.len() >= blf.placements.len(),
1069 "EP placed {} but BLF placed {} — EP must not regress below BLF",
1070 ep.placements.len(),
1071 blf.placements.len()
1072 );
1073 }
1074
1075 fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
1077 let geometries = vec![
1078 Geometry3D::new("a", 20.0, 20.0, 20.0),
1079 Geometry3D::new("b", 20.0, 20.0, 20.0),
1080 ];
1081 let mut result = SolveResult::new();
1082 result.placements.push(
1083 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1084 .with_rotation_index(0),
1085 );
1086 result.placements.push(
1088 Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1089 .with_rotation_index(0),
1090 );
1091 (geometries, result)
1092 }
1093
1094 #[test]
1095 fn test_gravity_removes_floating_box() {
1096 let (geometries, mut result) = floating_pair();
1097 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1098 let packer = Packer3D::default_config();
1099
1100 packer.enforce_support(&mut result, &geometries, &boundary);
1101
1102 let ids: Vec<&str> = result
1103 .placements
1104 .iter()
1105 .map(|p| p.geometry_id.as_str())
1106 .collect();
1107 assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1108 assert!(result.unplaced.iter().any(|id| id == "b"));
1109 }
1110
1111 #[test]
1112 fn test_no_constraint_keeps_floating_box() {
1113 let (geometries, result) = floating_pair();
1117 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1118 assert!(!boundary.has_gravity() && !boundary.has_stability());
1119 assert_eq!(result.placements.len(), 2);
1121 let _ = geometries;
1122 }
1123
1124 #[test]
1125 fn test_gravity_keeps_floor_boxes_with_margin() {
1126 let geometries = vec![
1131 Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1132 Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1133 ];
1134 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1135 let packer = Packer3D::new(
1136 Config::default()
1137 .with_margin(5.0)
1138 .with_strategy(Strategy::BottomLeftFill),
1139 );
1140
1141 let result = packer.solve(&geometries, &boundary).unwrap();
1142
1143 assert!(
1144 !result.placements.is_empty(),
1145 "margin>0 + gravity must not drop floor-resting boxes"
1146 );
1147 }
1148
1149 #[test]
1150 fn test_stability_drops_undersupported_box() {
1151 let geometries = vec![
1155 Geometry3D::new("a", 40.0, 40.0, 20.0),
1156 Geometry3D::new("b", 40.0, 40.0, 20.0),
1157 ];
1158 let make = || {
1159 let mut r = SolveResult::new();
1160 r.placements.push(
1161 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1162 .with_rotation_index(0),
1163 );
1164 r.placements.push(
1166 Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1167 .with_rotation_index(0),
1168 );
1169 r
1170 };
1171 let packer = Packer3D::default_config();
1172
1173 let mut grav = make();
1174 packer.enforce_support(
1175 &mut grav,
1176 &geometries,
1177 &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1178 );
1179 assert_eq!(
1180 grav.placements.len(),
1181 2,
1182 "gravity keeps a box that touches below"
1183 );
1184
1185 let mut stab = make();
1186 packer.enforce_support(
1187 &mut stab,
1188 &geometries,
1189 &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1190 );
1191 assert!(
1192 stab.placements.iter().all(|p| p.geometry_id == "a"),
1193 "stability drops the under-supported box"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_layer_packing_orientation_optimization() {
1199 use crate::geometry::OrientationConstraint;
1200
1201 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1204 .with_quantity(2)
1205 .with_orientation(OrientationConstraint::Any)];
1206
1207 let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1209 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1210 let packer = Packer3D::new(config);
1211
1212 let result = packer.solve(&geometries, &boundary).unwrap();
1213
1214 assert_eq!(
1216 result.placements.len(),
1217 2,
1218 "Both boxes should be placed by using rotation"
1219 );
1220 assert!(result.unplaced.is_empty());
1221
1222 for p in &result.placements {
1224 assert!(
1225 p.rotation_index.is_some(),
1226 "Placement should have rotation_index set"
1227 );
1228 }
1229 }
1230
1231 #[test]
1232 fn test_layer_packing_selects_best_orientation() {
1233 use crate::geometry::OrientationConstraint;
1234
1235 let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1240 .with_quantity(1)
1241 .with_orientation(OrientationConstraint::Any)];
1242
1243 let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1244 let packer = Packer3D::default_config();
1245
1246 let result = packer.solve(&geometries, &boundary).unwrap();
1247
1248 assert_eq!(result.placements.len(), 1);
1249 assert!(result.unplaced.is_empty());
1250 }
1251}