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