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::physics::{PhysicsConfig, PhysicsSimulator};
9use crate::sa_packing::run_sa_packing;
10use crate::stability::{PlacedBox, StabilityAnalyzer, StabilityConstraint, StabilityReport};
11use u_nesting_core::brkga::BrkgaConfig;
12use u_nesting_core::ga::GaConfig;
13use u_nesting_core::geom::nalgebra_types::{NaPoint3 as Point3, NaVector3 as Vector3};
14use u_nesting_core::geometry::{Boundary, Geometry};
15use u_nesting_core::sa::SaConfig;
16use u_nesting_core::solver::{Config, ProgressCallback, ProgressInfo, Solver, Strategy};
17use u_nesting_core::{Error, Placement, Result, SolveResult};
18
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use u_nesting_core::timing::Timer;
22
23type BestFit3D = Option<(usize, f64, f64, f64, f64, f64, f64, f64, f64)>;
26
27pub struct Packer3D {
29 config: Config,
30 cancelled: Arc<AtomicBool>,
31}
32
33impl Packer3D {
34 fn run_strategy(
37 &self,
38 geometries: &[Geometry3D],
39 boundary: &Boundary3D,
40 ) -> Result<SolveResult<f64>> {
41 match self.config.strategy {
42 Strategy::BottomLeftFill => self.layer_packing(geometries, boundary),
43 Strategy::ExtremePoint => self.extreme_point(geometries, boundary),
44 Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
45 Strategy::Brkga => self.brkga(geometries, boundary),
46 Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
47 other => Err(Error::invalid_option(
48 "strategy",
49 format!(
50 "strategy {other:?} is not available for 3D packing; it offers \
51 BottomLeftFill, ExtremePoint, GeneticAlgorithm, Brkga and SimulatedAnnealing"
52 ),
53 )),
54 }
55 }
56
57 pub fn new(config: Config) -> Self {
59 Self {
60 config,
61 cancelled: Arc::new(AtomicBool::new(false)),
62 }
63 }
64
65 pub fn default_config() -> Self {
67 Self::new(Config::default())
68 }
69
70 pub fn validate_stability(
74 &self,
75 result: &SolveResult<f64>,
76 geometries: &[Geometry3D],
77 _boundary: &Boundary3D,
78 constraint: StabilityConstraint,
79 ) -> StabilityReport {
80 let placed_boxes = self.placements_to_boxes(result, geometries);
82 let analyzer = StabilityAnalyzer::new(constraint);
83 analyzer.analyze(&placed_boxes, 0.0)
84 }
85
86 pub fn validate_stability_physics(
90 &self,
91 result: &SolveResult<f64>,
92 geometries: &[Geometry3D],
93 boundary: &Boundary3D,
94 ) -> StabilityReport {
95 let placed_boxes = self.placements_to_boxes(result, geometries);
96 let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
97
98 let config = PhysicsConfig::default().with_max_time(2.0);
99 let simulator = PhysicsSimulator::new(config);
100 simulator.validate_stability(&placed_boxes, container, 0.0)
101 }
102
103 fn enforce_support(
114 &self,
115 result: &mut SolveResult<f64>,
116 geometries: &[Geometry3D],
117 boundary: &Boundary3D,
118 ) {
119 let constraint = if boundary.has_stability() {
122 StabilityConstraint::partial_base(0.7)
123 } else {
124 StabilityConstraint::partial_base(1e-6)
125 };
126
127 let floor_z = self.config.margin;
131 let analyzer = StabilityAnalyzer::new(constraint);
132 while !result.placements.is_empty() {
133 let placed_boxes = self.placements_to_boxes(result, geometries);
134 let report = analyzer.analyze(&placed_boxes, floor_z);
135 if report.is_all_stable() {
136 break;
137 }
138 let drop: std::collections::HashSet<(String, usize)> = report
139 .unstable_boxes()
140 .iter()
141 .map(|r| (r.id.clone(), r.instance))
142 .collect();
143 result
144 .placements
145 .retain(|p| !drop.contains(&(p.geometry_id.clone(), p.instance)));
146 for (id, _) in drop {
147 result.unplaced.push(id);
148 }
149 }
150
151 result.deduplicate_unplaced();
152 let boxes = self.placements_to_boxes(result, geometries);
154 let placed_volume: f64 = boxes
155 .iter()
156 .map(|b| b.dimensions.x * b.dimensions.y * b.dimensions.z)
157 .sum();
158 let container_volume = boundary.measure();
159 result.utilization = if container_volume > 0.0 {
160 placed_volume / container_volume
161 } else {
162 0.0
163 };
164 }
165
166 fn placements_to_boxes(
168 &self,
169 result: &SolveResult<f64>,
170 geometries: &[Geometry3D],
171 ) -> Vec<PlacedBox> {
172 let geom_map: std::collections::HashMap<&str, &Geometry3D> =
173 geometries.iter().map(|g| (g.id().as_str(), g)).collect();
174
175 result
176 .placements
177 .iter()
178 .filter_map(|p| {
179 let geom = geom_map.get(p.geometry_id.as_str())?;
180 let ori_idx = p.rotation_index.unwrap_or(0);
181 let dims = geom.dimensions_for_orientation(ori_idx);
182
183 let mut placed = PlacedBox::new(
184 p.geometry_id.clone(),
185 p.instance,
186 Point3::new(p.position[0], p.position[1], p.position[2]),
187 dims,
188 );
189
190 if let Some(mass) = geom.mass() {
191 placed = placed.with_mass(mass);
192 }
193
194 Some(placed)
195 })
196 .collect()
197 }
198
199 fn layer_packing(
201 &self,
202 geometries: &[Geometry3D],
203 boundary: &Boundary3D,
204 ) -> Result<SolveResult<f64>> {
205 let start = Timer::now();
206 let mut result = SolveResult::new();
207 let mut placements = Vec::new();
208
209 let margin = self.config.margin;
210 let spacing = self.config.spacing;
211
212 let bound_max_x = boundary.width() - margin;
213 let bound_max_y = boundary.depth() - margin;
214 let bound_max_z = boundary.height() - margin;
215
216 let mut current_x = margin;
218 let mut current_y = margin;
219 let mut current_z = margin;
220 let mut row_depth = 0.0_f64;
221 let mut layer_height = 0.0_f64;
222
223 let mut total_placed_volume = 0.0;
224 let mut total_placed_mass = 0.0;
225
226 for geom in geometries {
227 geom.validate()?;
228
229 for instance in 0..geom.quantity() {
230 if self.cancelled.load(Ordering::Relaxed) {
231 result.computation_time_ms = start.elapsed_ms();
232 return Ok(result);
233 }
234
235 if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms
237 {
238 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
239 result.utilization = total_placed_volume / boundary.measure();
240 result.computation_time_ms = start.elapsed_ms();
241 result.placements = placements;
242 return Ok(result);
243 }
244
245 if let (Some(max_mass), Some(item_mass)) = (boundary.max_mass(), geom.mass()) {
247 if total_placed_mass + item_mass > max_mass {
248 result.unplaced.push(geom.id().clone());
249 continue;
250 }
251 }
252
253 let orientations = geom.allowed_orientations();
255 let mut best_fit: BestFit3D = None;
256 for (ori_idx, _) in orientations.iter().enumerate() {
259 let dims = geom.dimensions_for_orientation(ori_idx);
260 let g_width = dims.x;
261 let g_depth = dims.y;
262 let g_height = dims.z;
263
264 let mut try_x = current_x;
266 let mut try_y = current_y;
267 let mut try_z = current_z;
268 let mut try_row_depth = row_depth;
269 let mut try_layer_height = layer_height;
270
271 if try_x + g_width > bound_max_x {
273 try_x = margin;
274 try_y += row_depth + spacing;
275 try_row_depth = 0.0;
276 }
277
278 if try_y + g_depth > bound_max_y {
280 try_x = margin;
281 try_y = margin;
282 try_z += layer_height + spacing;
283 try_row_depth = 0.0;
284 try_layer_height = 0.0;
285 }
286
287 if try_x + g_width > bound_max_x
292 || try_y + g_depth > bound_max_y
293 || try_z + g_height > bound_max_z
294 {
295 continue; }
297
298 let score = try_z * 1000000.0 + try_y * 1000.0 + try_x + g_height * 0.1;
301
302 let is_better = match &best_fit {
303 None => true,
304 Some((_, _, _, bg_height, bx, by, bz, _, _)) => {
305 let best_score = bz * 1000000.0 + by * 1000.0 + bx + bg_height * 0.1;
306 score < best_score
307 }
308 };
309
310 if is_better {
311 best_fit = Some((
312 ori_idx,
313 g_width,
314 g_depth,
315 g_height,
316 try_x,
317 try_y,
318 try_z,
319 try_row_depth,
320 try_layer_height,
321 ));
322 }
323 }
324
325 if let Some((
326 ori_idx,
327 g_width,
328 g_depth,
329 g_height,
330 place_x,
331 place_y,
332 place_z,
333 new_row_depth,
334 new_layer_height,
335 )) = best_fit
336 {
337 let placement = Placement::new_3d(
340 geom.id().clone(),
341 instance,
342 place_x,
343 place_y,
344 place_z,
345 0.0, 0.0,
347 0.0,
348 )
349 .with_rotation_index(ori_idx);
350
351 placements.push(placement);
352 total_placed_volume += geom.measure();
353 if let Some(mass) = geom.mass() {
354 total_placed_mass += mass;
355 }
356
357 current_x = place_x + g_width + spacing;
359 current_y = place_y;
360 current_z = place_z;
361 row_depth = new_row_depth.max(g_depth);
362 layer_height = new_layer_height.max(g_height);
363 } else {
364 result.unplaced.push(geom.id().clone());
365 }
366 }
367 }
368
369 result.placements = placements;
370 result.boundaries_used = 1;
371 result.utilization = total_placed_volume / boundary.measure();
372 result.computation_time_ms = start.elapsed_ms();
373
374 Ok(result)
375 }
376
377 fn genetic_algorithm(
382 &self,
383 geometries: &[Geometry3D],
384 boundary: &Boundary3D,
385 ) -> Result<SolveResult<f64>> {
386 let ga_config = GaConfig::default()
388 .with_population_size(self.config.population_size)
389 .with_max_generations(self.config.max_generations)
390 .with_crossover_rate(self.config.crossover_rate)
391 .with_mutation_rate(self.config.mutation_rate);
392
393 let result = run_ga_packing(
394 geometries,
395 boundary,
396 &self.config,
397 ga_config,
398 self.cancelled.clone(),
399 )?;
400
401 Ok(result)
402 }
403
404 fn brkga(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<SolveResult<f64>> {
408 let brkga_config = BrkgaConfig::default()
410 .with_population_size(50)
411 .with_max_generations(100)
412 .with_elite_fraction(0.2)
413 .with_mutant_fraction(0.15)
414 .with_elite_bias(0.7);
415
416 let result = run_brkga_packing(
417 geometries,
418 boundary,
419 &self.config,
420 brkga_config,
421 self.cancelled.clone(),
422 )?;
423
424 Ok(result)
425 }
426
427 fn simulated_annealing(
432 &self,
433 geometries: &[Geometry3D],
434 boundary: &Boundary3D,
435 ) -> Result<SolveResult<f64>> {
436 let sa_config = SaConfig::default()
438 .with_initial_temp(100.0)
439 .with_final_temp(0.1)
440 .with_cooling_rate(0.95)
441 .with_iterations_per_temp(50)
442 .with_max_iterations(10000);
443
444 let result = run_sa_packing(
445 geometries,
446 boundary,
447 &self.config,
448 sa_config,
449 self.cancelled.clone(),
450 )?;
451
452 Ok(result)
453 }
454
455 fn extreme_point(
460 &self,
461 geometries: &[Geometry3D],
462 boundary: &Boundary3D,
463 ) -> Result<SolveResult<f64>> {
464 let start = Timer::now();
465
466 let (ep_placements, utilization) = run_ep_packing(
467 geometries,
468 boundary,
469 self.config.margin,
470 self.config.spacing,
471 boundary.max_mass(),
472 );
473
474 let mut placements = Vec::new();
479 for (id, instance, position, orientation) in ep_placements {
480 let placement = Placement::new_3d(
481 id, instance, position.x, position.y, position.z, 0.0, 0.0, 0.0, )
485 .with_rotation_index(orientation);
486 placements.push(placement);
487 }
488
489 let mut placed_ids: std::collections::HashSet<(String, usize)> =
491 std::collections::HashSet::new();
492 for p in &placements {
493 placed_ids.insert((p.geometry_id.clone(), p.instance));
494 }
495
496 let mut unplaced = Vec::new();
497 for geom in geometries {
498 for instance in 0..geom.quantity() {
499 if !placed_ids.contains(&(geom.id().clone(), instance)) {
500 unplaced.push(geom.id().clone());
501 }
502 }
503 }
504
505 let mut result = SolveResult::new();
506 result.placements = placements;
507 result.boundaries_used = 1;
508 result.utilization = utilization;
509 result.unplaced = unplaced;
510 result.computation_time_ms = start.elapsed_ms();
511 result.strategy = Some("ExtremePoint".to_string());
512
513 Ok(result)
514 }
515
516 fn layer_packing_with_progress(
518 &self,
519 geometries: &[Geometry3D],
520 boundary: &Boundary3D,
521 callback: &ProgressCallback,
522 ) -> Result<SolveResult<f64>> {
523 let start = Timer::now();
524 let mut result = SolveResult::new();
525 let mut placements = Vec::new();
526
527 let margin = self.config.margin;
528 let spacing = self.config.spacing;
529
530 let bound_max_x = boundary.width() - margin;
531 let bound_max_y = boundary.depth() - margin;
532 let bound_max_z = boundary.height() - margin;
533
534 let mut current_x = margin;
535 let mut current_y = margin;
536 let mut current_z = margin;
537 let mut row_depth = 0.0_f64;
538 let mut layer_height = 0.0_f64;
539
540 let mut total_placed_volume = 0.0;
541 let mut total_placed_mass = 0.0;
542
543 let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
545 let mut placed_count = 0usize;
546
547 callback(
549 ProgressInfo::new()
550 .with_phase("Layer Packing")
551 .with_items(0, total_pieces)
552 .with_elapsed(0),
553 );
554
555 for geom in geometries {
556 geom.validate()?;
557
558 for instance in 0..geom.quantity() {
559 if self.cancelled.load(Ordering::Relaxed) {
560 result.computation_time_ms = start.elapsed_ms();
561 callback(
562 ProgressInfo::new()
563 .with_phase("Cancelled")
564 .with_items(placed_count, total_pieces)
565 .with_elapsed(result.computation_time_ms)
566 .finished(),
567 );
568 return Ok(result);
569 }
570
571 if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms
573 {
574 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
575 result.utilization = total_placed_volume / boundary.measure();
576 result.computation_time_ms = start.elapsed_ms();
577 result.placements = placements;
578 callback(
579 ProgressInfo::new()
580 .with_phase("Time Limit Reached")
581 .with_items(placed_count, total_pieces)
582 .with_elapsed(result.computation_time_ms)
583 .finished(),
584 );
585 return Ok(result);
586 }
587
588 if let (Some(max_mass), Some(item_mass)) = (boundary.max_mass(), geom.mass()) {
590 if total_placed_mass + item_mass > max_mass {
591 result.unplaced.push(geom.id().clone());
592 continue;
593 }
594 }
595
596 let orientations = geom.allowed_orientations();
598 let mut best_fit: BestFit3D = None;
599
600 for (ori_idx, _) in orientations.iter().enumerate() {
601 let dims = geom.dimensions_for_orientation(ori_idx);
602 let g_width = dims.x;
603 let g_depth = dims.y;
604 let g_height = dims.z;
605
606 let mut try_x = current_x;
607 let mut try_y = current_y;
608 let mut try_z = current_z;
609 let mut try_row_depth = row_depth;
610 let mut try_layer_height = layer_height;
611
612 if try_x + g_width > bound_max_x {
613 try_x = margin;
614 try_y += row_depth + spacing;
615 try_row_depth = 0.0;
616 }
617
618 if try_y + g_depth > bound_max_y {
619 try_x = margin;
620 try_y = margin;
621 try_z += layer_height + spacing;
622 try_row_depth = 0.0;
623 try_layer_height = 0.0;
624 }
625
626 if try_x + g_width > bound_max_x
627 || try_y + g_depth > bound_max_y
628 || try_z + g_height > bound_max_z
629 {
630 continue;
631 }
632
633 let score = try_z * 1000000.0 + try_y * 1000.0 + try_x + g_height * 0.1;
634
635 let is_better = match &best_fit {
636 None => true,
637 Some((_, _, _, bg_height, bx, by, bz, _, _)) => {
638 let best_score = bz * 1000000.0 + by * 1000.0 + bx + bg_height * 0.1;
639 score < best_score
640 }
641 };
642
643 if is_better {
644 best_fit = Some((
645 ori_idx,
646 g_width,
647 g_depth,
648 g_height,
649 try_x,
650 try_y,
651 try_z,
652 try_row_depth,
653 try_layer_height,
654 ));
655 }
656 }
657
658 if let Some((
659 ori_idx,
660 g_width,
661 g_depth,
662 g_height,
663 place_x,
664 place_y,
665 place_z,
666 new_row_depth,
667 new_layer_height,
668 )) = best_fit
669 {
670 let placement = Placement::new_3d(
671 geom.id().clone(),
672 instance,
673 place_x,
674 place_y,
675 place_z,
676 0.0,
677 0.0,
678 0.0,
679 )
680 .with_rotation_index(ori_idx);
681
682 placements.push(placement);
683 total_placed_volume += geom.measure();
684 if let Some(mass) = geom.mass() {
685 total_placed_mass += mass;
686 }
687 placed_count += 1;
688
689 current_x = place_x + g_width + spacing;
690 current_y = place_y;
691 current_z = place_z;
692 row_depth = new_row_depth.max(g_depth);
693 layer_height = new_layer_height.max(g_height);
694
695 callback(
697 ProgressInfo::new()
698 .with_phase("Layer Packing")
699 .with_items(placed_count, total_pieces)
700 .with_utilization(total_placed_volume / boundary.measure())
701 .with_elapsed(start.elapsed_ms()),
702 );
703 } else {
704 result.unplaced.push(geom.id().clone());
705 }
706 }
707 }
708
709 result.placements = placements;
710 result.boundaries_used = 1;
711 result.utilization = total_placed_volume / boundary.measure();
712 result.computation_time_ms = start.elapsed_ms();
713
714 callback(
716 ProgressInfo::new()
717 .with_phase("Complete")
718 .with_items(placed_count, total_pieces)
719 .with_utilization(result.utilization)
720 .with_elapsed(result.computation_time_ms)
721 .finished(),
722 );
723
724 Ok(result)
725 }
726}
727
728impl Solver for Packer3D {
729 type Geometry = Geometry3D;
730 type Boundary = Boundary3D;
731 type Scalar = f64;
732
733 fn solve(
734 &self,
735 geometries: &[Self::Geometry],
736 boundary: &Self::Boundary,
737 ) -> Result<SolveResult<f64>> {
738 self.config.validate()?;
739 boundary.validate()?;
740 u_nesting_core::geometry::ensure_unique_ids(geometries)?;
741
742 self.cancelled.store(false, Ordering::Relaxed);
744
745 let mut result = self.run_strategy(geometries, boundary)?;
746
747 result.deduplicate_unplaced();
749 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
752
753 if boundary.has_gravity() || boundary.has_stability() {
755 self.enforce_support(&mut result, geometries, boundary);
756 }
757 Ok(result)
758 }
759
760 fn solve_with_progress(
761 &self,
762 geometries: &[Self::Geometry],
763 boundary: &Self::Boundary,
764 callback: ProgressCallback,
765 ) -> Result<SolveResult<f64>> {
766 boundary.validate()?;
767 u_nesting_core::geometry::ensure_unique_ids(geometries)?;
768
769 self.cancelled.store(false, Ordering::Relaxed);
771
772 let mut result = match self.config.strategy {
773 Strategy::BottomLeftFill => {
774 self.layer_packing_with_progress(geometries, boundary, &callback)?
775 }
776 Strategy::ExtremePoint => self.extreme_point(geometries, boundary)?,
779 _ => self.run_strategy(geometries, boundary)?,
783 };
784
785 result.deduplicate_unplaced();
787 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
790
791 if boundary.has_gravity() || boundary.has_stability() {
793 self.enforce_support(&mut result, geometries, boundary);
794 }
795 Ok(result)
796 }
797
798 fn cancel(&self) {
799 self.cancelled.store(true, Ordering::Relaxed);
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 #[test]
808 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
809 let geometries = [
810 Geometry3D::new("B", 10.0, 10.0, 10.0),
811 Geometry3D::new("B", 50.0, 50.0, 50.0),
812 ];
813 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
814 let packer = Packer3D::default_config();
815 let errors = [
816 packer.solve(&geometries, &boundary).expect_err("solve"),
817 packer
818 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
819 .expect_err("solve_with_progress"),
820 ];
821 for err in errors {
822 let msg = err.to_string();
823 assert!(msg.contains("'B'"), "{msg}");
824 assert!(msg.contains("positions 0 and 1"), "{msg}");
825 }
826 }
827
828 #[test]
829 fn test_simple_packing() {
830 let geometries = vec![
831 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
832 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
833 ];
834
835 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
836 let packer = Packer3D::default_config();
837
838 let result = packer.solve(&geometries, &boundary).unwrap();
839
840 assert!(result.utilization > 0.0);
841 assert!(result.placements.len() <= 5);
842 }
843
844 #[test]
845 fn test_mass_constraint() {
846 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
847 .with_quantity(10)
848 .with_mass(100.0)];
849
850 let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
851
852 let packer = Packer3D::default_config();
853 let result = packer.solve(&geometries, &boundary).unwrap();
854
855 assert!(result.placements.len() <= 3);
857 }
858
859 #[test]
860 fn test_placement_within_bounds() {
861 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
862
863 let boundary = Boundary3D::new(50.0, 50.0, 50.0);
864 let config = Config::default().with_margin(5.0).with_spacing(2.0);
865 let packer = Packer3D::new(config);
866
867 let result = packer.solve(&geometries, &boundary).unwrap();
868
869 assert_eq!(result.placements.len(), 4);
871 assert!(result.unplaced.is_empty());
872
873 for p in &result.placements {
875 assert!(p.position[0] >= 5.0);
876 assert!(p.position[1] >= 5.0);
877 assert!(p.position[2] >= 5.0);
878 }
879 }
880
881 #[test]
882 fn test_ga_strategy_basic() {
883 let geometries = vec![
884 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
885 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
886 ];
887
888 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
889 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
890 let packer = Packer3D::new(config);
891
892 let result = packer.solve(&geometries, &boundary).unwrap();
893
894 assert!(result.utilization > 0.0);
896 assert!(!result.placements.is_empty());
897 }
898
899 #[test]
900 fn test_ga_strategy_all_placed() {
901 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
903
904 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
905 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
906 let packer = Packer3D::new(config);
907
908 let result = packer.solve(&geometries, &boundary).unwrap();
909
910 assert_eq!(result.placements.len(), 4);
912 assert!(result.unplaced.is_empty());
913 }
914
915 #[test]
916 #[cfg(feature = "serde")]
917 fn test_3d_response_orientation_in_bounds() {
918 use crate::build_pack3d_response;
919 use crate::geometry::OrientationConstraint;
920
921 let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
927 let boundary = Boundary3D::new(bw, bd, bh);
928
929 for strategy in [
930 Strategy::ExtremePoint,
931 Strategy::GeneticAlgorithm,
932 Strategy::Brkga,
933 ] {
934 let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
935 .with_quantity(3)
936 .with_orientation(OrientationConstraint::Any)];
937 let config = Config::default().with_strategy(strategy);
938 let packer = Packer3D::new(config);
939 let result = packer.solve(&geometries, &boundary).unwrap();
940 let response = build_pack3d_response(&result, &geometries);
941
942 if strategy == Strategy::ExtremePoint {
944 assert!(
945 !response.placements.is_empty(),
946 "EP should place the tall box by rotating it into the flat container"
947 );
948 }
949
950 let base = geometries[0].dimensions_for_orientation(0); for p in &response.placements {
952 let axes: Vec<usize> = p
953 .orientation
954 .chars()
955 .map(|c| match c {
956 'x' => 0,
957 'y' => 1,
958 'z' => 2,
959 _ => panic!("unexpected orientation label '{}'", p.orientation),
960 })
961 .collect();
962 let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
963 let e = 1e-6;
964 assert!(
965 p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
966 "{:?} {}#{} out of bounds for reported orientation '{}': \
967 pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
968 strategy,
969 p.geometry_id,
970 p.instance,
971 p.orientation,
972 p.x,
973 p.y,
974 p.z,
975 );
976 }
977 }
978 }
979
980 #[test]
981 fn test_ga_strategy_with_orientations() {
982 use crate::geometry::OrientationConstraint;
983
984 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
986 .with_quantity(2)
987 .with_orientation(OrientationConstraint::Any)];
988
989 let boundary = Boundary3D::new(60.0, 60.0, 60.0);
991 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
992 let packer = Packer3D::new(config);
993
994 let result = packer.solve(&geometries, &boundary).unwrap();
995
996 assert_eq!(result.placements.len(), 2);
998 }
999
1000 #[test]
1001 fn test_brkga_strategy_basic() {
1002 let geometries = vec![
1003 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
1004 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
1005 ];
1006
1007 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
1008 let config = Config::default().with_strategy(Strategy::Brkga);
1009 let packer = Packer3D::new(config);
1010
1011 let result = packer.solve(&geometries, &boundary).unwrap();
1012
1013 assert!(result.utilization > 0.0);
1015 assert!(!result.placements.is_empty());
1016 assert_eq!(result.strategy, Some("BRKGA".to_string()));
1017 }
1018
1019 #[test]
1020 fn test_brkga_strategy_all_placed() {
1021 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
1023
1024 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1025 let config = Config::default().with_strategy(Strategy::Brkga);
1026 let packer = Packer3D::new(config);
1027
1028 let result = packer.solve(&geometries, &boundary).unwrap();
1029
1030 assert_eq!(result.placements.len(), 4);
1032 assert!(result.unplaced.is_empty());
1033 }
1034
1035 #[test]
1036 fn test_ep_strategy_basic() {
1037 let geometries = vec![
1038 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
1039 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
1040 ];
1041
1042 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
1043 let config = Config::default().with_strategy(Strategy::ExtremePoint);
1044 let packer = Packer3D::new(config);
1045
1046 let result = packer.solve(&geometries, &boundary).unwrap();
1047
1048 assert!(result.utilization > 0.0);
1050 assert!(!result.placements.is_empty());
1051 assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
1052 }
1053
1054 #[test]
1055 fn test_ep_strategy_all_placed() {
1056 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
1058
1059 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1060 let config = Config::default().with_strategy(Strategy::ExtremePoint);
1061 let packer = Packer3D::new(config);
1062
1063 let result = packer.solve(&geometries, &boundary).unwrap();
1064
1065 assert_eq!(result.placements.len(), 4);
1067 assert!(result.unplaced.is_empty());
1068 }
1069
1070 #[test]
1071 fn test_ep_strategy_with_margin() {
1072 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
1073
1074 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1075 let config = Config::default()
1076 .with_strategy(Strategy::ExtremePoint)
1077 .with_margin(5.0);
1078 let packer = Packer3D::new(config);
1079
1080 let result = packer.solve(&geometries, &boundary).unwrap();
1081
1082 for p in &result.placements {
1084 assert!(p.position[0] >= 4.9);
1085 assert!(p.position[1] >= 4.9);
1086 assert!(p.position[2] >= 4.9);
1087 }
1088 }
1089
1090 #[test]
1091 fn test_ep_strategy_with_orientations() {
1092 use crate::geometry::OrientationConstraint;
1093
1094 let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
1096 .with_quantity(2)
1097 .with_orientation(OrientationConstraint::Any)];
1098
1099 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1100 let config = Config::default().with_strategy(Strategy::ExtremePoint);
1101 let packer = Packer3D::new(config);
1102
1103 let result = packer.solve(&geometries, &boundary).unwrap();
1104
1105 assert_eq!(result.placements.len(), 2);
1107 }
1108
1109 #[test]
1110 fn test_ep_strategy_perfect_fill_via_solve() {
1111 let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
1115 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1116 let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
1117
1118 let result = packer.solve(&geometries, &boundary).unwrap();
1119
1120 assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
1121 assert!(result.unplaced.is_empty());
1122 }
1123
1124 #[test]
1125 fn test_ep_at_least_as_good_as_blf() {
1126 let boundary = Boundary3D::new(85.0, 85.0, 80.0);
1130 let cases: [(&str, f64, f64, f64, usize); 3] = [
1131 ("big", 40.0, 40.0, 40.0, 4),
1132 ("mid", 30.0, 20.0, 25.0, 6),
1133 ("small", 15.0, 15.0, 30.0, 8),
1134 ];
1135 let geometries: Vec<Geometry3D> = cases
1136 .iter()
1137 .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
1138 .collect();
1139
1140 let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
1141 .solve(&geometries, &boundary)
1142 .unwrap();
1143 let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
1144 .solve(&geometries, &boundary)
1145 .unwrap();
1146
1147 assert!(
1148 ep.placements.len() >= blf.placements.len(),
1149 "EP placed {} but BLF placed {} — EP must not regress below BLF",
1150 ep.placements.len(),
1151 blf.placements.len()
1152 );
1153 }
1154
1155 fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
1157 let geometries = vec![
1158 Geometry3D::new("a", 20.0, 20.0, 20.0),
1159 Geometry3D::new("b", 20.0, 20.0, 20.0),
1160 ];
1161 let mut result = SolveResult::new();
1162 result.placements.push(
1163 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1164 .with_rotation_index(0),
1165 );
1166 result.placements.push(
1168 Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1169 .with_rotation_index(0),
1170 );
1171 (geometries, result)
1172 }
1173
1174 #[test]
1175 fn test_gravity_removes_floating_box() {
1176 let (geometries, mut result) = floating_pair();
1177 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1178 let packer = Packer3D::default_config();
1179
1180 packer.enforce_support(&mut result, &geometries, &boundary);
1181
1182 let ids: Vec<&str> = result
1183 .placements
1184 .iter()
1185 .map(|p| p.geometry_id.as_str())
1186 .collect();
1187 assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1188 assert!(result.unplaced.iter().any(|id| id == "b"));
1189 }
1190
1191 #[test]
1192 fn test_no_constraint_keeps_floating_box() {
1193 let (geometries, result) = floating_pair();
1197 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1198 assert!(!boundary.has_gravity() && !boundary.has_stability());
1199 assert_eq!(result.placements.len(), 2);
1201 let _ = geometries;
1202 }
1203
1204 #[test]
1205 fn test_gravity_keeps_floor_boxes_with_margin() {
1206 let geometries = vec![
1211 Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1212 Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1213 ];
1214 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1215 let packer = Packer3D::new(
1216 Config::default()
1217 .with_margin(5.0)
1218 .with_strategy(Strategy::BottomLeftFill),
1219 );
1220
1221 let result = packer.solve(&geometries, &boundary).unwrap();
1222
1223 assert!(
1224 !result.placements.is_empty(),
1225 "margin>0 + gravity must not drop floor-resting boxes"
1226 );
1227 }
1228
1229 #[test]
1230 fn test_stability_drops_undersupported_box() {
1231 let geometries = vec![
1235 Geometry3D::new("a", 40.0, 40.0, 20.0),
1236 Geometry3D::new("b", 40.0, 40.0, 20.0),
1237 ];
1238 let make = || {
1239 let mut r = SolveResult::new();
1240 r.placements.push(
1241 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1242 .with_rotation_index(0),
1243 );
1244 r.placements.push(
1246 Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1247 .with_rotation_index(0),
1248 );
1249 r
1250 };
1251 let packer = Packer3D::default_config();
1252
1253 let mut grav = make();
1254 packer.enforce_support(
1255 &mut grav,
1256 &geometries,
1257 &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1258 );
1259 assert_eq!(
1260 grav.placements.len(),
1261 2,
1262 "gravity keeps a box that touches below"
1263 );
1264
1265 let mut stab = make();
1266 packer.enforce_support(
1267 &mut stab,
1268 &geometries,
1269 &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1270 );
1271 assert!(
1272 stab.placements.iter().all(|p| p.geometry_id == "a"),
1273 "stability drops the under-supported box"
1274 );
1275 }
1276
1277 #[test]
1278 fn test_layer_packing_orientation_optimization() {
1279 use crate::geometry::OrientationConstraint;
1280
1281 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1284 .with_quantity(2)
1285 .with_orientation(OrientationConstraint::Any)];
1286
1287 let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1289 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1290 let packer = Packer3D::new(config);
1291
1292 let result = packer.solve(&geometries, &boundary).unwrap();
1293
1294 assert_eq!(
1296 result.placements.len(),
1297 2,
1298 "Both boxes should be placed by using rotation"
1299 );
1300 assert!(result.unplaced.is_empty());
1301
1302 for p in &result.placements {
1304 assert!(
1305 p.rotation_index.is_some(),
1306 "Placement should have rotation_index set"
1307 );
1308 }
1309 }
1310
1311 #[test]
1312 fn test_layer_packing_selects_best_orientation() {
1313 use crate::geometry::OrientationConstraint;
1314
1315 let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1320 .with_quantity(1)
1321 .with_orientation(OrientationConstraint::Any)];
1322
1323 let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1324 let packer = Packer3D::default_config();
1325
1326 let result = packer.solve(&geometries, &boundary).unwrap();
1327
1328 assert_eq!(result.placements.len(), 1);
1329 assert!(result.unplaced.is_empty());
1330 }
1331}