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