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
386 let mut result = self.run_strategy(geometries, boundary, progress)?;
387
388 result.record_unplaced(geometries);
390 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
393
394 if boundary.has_gravity() || boundary.has_stability() {
396 self.enforce_support(&mut result, geometries, boundary);
397 }
398 Ok(result)
399 }
400
401 fn stop_requested(&self, start: &Timer) -> Option<StopReason> {
404 if self.cancelled.load(Ordering::Relaxed) {
405 Some(StopReason::Cancelled)
406 } else if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms {
407 Some(StopReason::TimeLimit)
408 } else {
409 None
410 }
411 }
412
413 fn search_time_limit(&self, started: &Timer) -> Option<std::time::Duration> {
416 (self.config.time_limit_ms > 0).then(|| {
417 std::time::Duration::from_millis(
418 self.config
419 .time_limit_ms
420 .saturating_sub(started.elapsed_ms())
421 .max(1),
422 )
423 })
424 }
425
426 fn not_worse_than_fills(
436 &self,
437 search: SolveResult<f64>,
438 geometries: &[Geometry3D],
439 boundary: &Boundary3D,
440 layer_fill: SolveResult<f64>,
441 ) -> SolveResult<f64> {
442 let supported = |mut r: SolveResult<f64>| {
443 if boundary.has_gravity() || boundary.has_stability() {
444 self.enforce_support(&mut r, geometries, boundary);
445 }
446 r
447 };
448 let fills = [
449 Some(layer_fill),
450 self.extreme_point(geometries, boundary, None).ok(),
451 ]
452 .into_iter()
453 .flatten()
454 .map(supported);
455 supported(search).floored_at(fills, |a, b| {
456 let (pa, pb) = (a.placements.len(), b.placements.len());
457 pa > pb || (pa == pb && a.utilization > b.utilization + 1e-9)
458 })
459 }
460
461 fn genetic_algorithm(
466 &self,
467 geometries: &[Geometry3D],
468 boundary: &Boundary3D,
469 progress: Option<&ProgressCallback>,
470 ) -> Result<SolveResult<f64>> {
471 let started = Timer::now();
472 let fill = self.layer_fill(geometries, boundary, progress);
473 let mut ga_config = self.ga_config();
474 ga_config.time_limit = self.search_time_limit(&started);
475 let result = run_ga_packing(
476 geometries,
477 boundary,
478 &self.config,
479 ga_config,
480 self.cancelled.clone(),
481 Some(&fill.placements),
482 progress,
483 )?;
484 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
485 }
486
487 fn brkga(
492 &self,
493 geometries: &[Geometry3D],
494 boundary: &Boundary3D,
495 progress: Option<&ProgressCallback>,
496 ) -> Result<SolveResult<f64>> {
497 let started = Timer::now();
498 let fill = self.layer_fill(geometries, boundary, progress);
499 let mut brkga_config = self.brkga_config();
500 brkga_config.time_limit = self.search_time_limit(&started);
501 let result = run_brkga_packing(
502 geometries,
503 boundary,
504 &self.config,
505 brkga_config,
506 self.cancelled.clone(),
507 Some(&fill.placements),
508 progress,
509 )?;
510 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
511 }
512
513 fn simulated_annealing(
518 &self,
519 geometries: &[Geometry3D],
520 boundary: &Boundary3D,
521 progress: Option<&ProgressCallback>,
522 ) -> Result<SolveResult<f64>> {
523 let started = Timer::now();
524 let fill = self.layer_fill(geometries, boundary, progress);
525 let mut sa_config = SaConfig::default()
526 .with_initial_temp(100.0)
527 .with_final_temp(0.1)
528 .with_cooling_rate(0.95)
529 .with_iterations_per_temp(50)
530 .with_max_iterations(self.config.max_iterations.unwrap_or(10000));
531 sa_config.time_limit = self.search_time_limit(&started);
532 sa_config.target_fitness = self.target_fitness();
533
534 let result = run_sa_packing(
535 geometries,
536 boundary,
537 &self.config,
538 sa_config,
539 self.cancelled.clone(),
540 Some(&fill.placements),
541 progress,
542 )?;
543 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
544 }
545
546 fn extreme_point(
551 &self,
552 geometries: &[Geometry3D],
553 boundary: &Boundary3D,
554 progress: Option<&ProgressCallback>,
555 ) -> Result<SolveResult<f64>> {
556 let start = Timer::now();
557 let total: usize = geometries.iter().map(|g| g.quantity()).sum();
558 let report = |info: ProgressInfo| {
559 if let Some(callback) = progress {
560 callback(info.with_phase("Extreme Point"));
561 }
562 };
563
564 let mut stop = None;
565 let (ep_placements, utilization) = run_ep_packing(
566 geometries,
567 boundary,
568 self.config.margin,
569 self.config.spacing,
570 boundary.max_mass(),
571 || {
572 report(
574 ProgressInfo::new()
575 .with_items(0, total)
576 .with_elapsed(start.elapsed_ms()),
577 );
578 stop = self.stop_requested(&start);
579 stop.is_some()
580 },
581 );
582
583 let mut placements = Vec::new();
588 for (id, instance, position, orientation) in ep_placements {
589 let placement = Placement::new_3d(
590 id, instance, position.x, position.y, position.z, 0.0, 0.0, 0.0, )
594 .with_rotation_index(orientation);
595 placements.push(placement);
596 }
597
598 let unplaced = build_unplaced_list(&placements, geometries);
599
600 let mut result = SolveResult::new();
601 result.boundaries_used = usize::from(!placements.is_empty());
602 result.placements = placements;
603 result.utilization = utilization;
604 if let Some(stop) = stop {
605 result.stop_reason = stop;
606 }
607 result.unplaced = unplaced;
608 result.computation_time_ms = start.elapsed_ms();
609 result.strategy = Some("ExtremePoint".to_string());
610 report(
611 ProgressInfo::new()
612 .with_items(result.placements.len(), total)
613 .with_utilization(result.utilization)
614 .with_elapsed(result.computation_time_ms)
615 .finished(),
616 );
617
618 Ok(result)
619 }
620}
621
622impl Solver for Packer3D {
623 type Geometry = Geometry3D;
624 type Boundary = Boundary3D;
625 type Scalar = f64;
626
627 fn solve(
628 &self,
629 geometries: &[Self::Geometry],
630 boundary: &Self::Boundary,
631 ) -> Result<SolveResult<f64>> {
632 self.solve_reporting(geometries, boundary, None)
633 }
634
635 fn solve_with_progress(
636 &self,
637 geometries: &[Self::Geometry],
638 boundary: &Self::Boundary,
639 callback: ProgressCallback,
640 ) -> Result<SolveResult<f64>> {
641 self.solve_reporting(geometries, boundary, Some(&callback))
642 }
643
644 fn cancel(&self) {
645 self.cancelled.store(true, Ordering::Relaxed);
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
655 let geometries = [
656 Geometry3D::new("B", 10.0, 10.0, 10.0),
657 Geometry3D::new("B", 50.0, 50.0, 50.0),
658 ];
659 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
660 let packer = Packer3D::default_config();
661 let errors = [
662 packer.solve(&geometries, &boundary).expect_err("solve"),
663 packer
664 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
665 .expect_err("solve_with_progress"),
666 ];
667 for err in errors {
668 let msg = err.to_string();
669 assert!(msg.contains("'B'"), "{msg}");
670 assert!(msg.contains("positions 0 and 1"), "{msg}");
671 }
672 }
673
674 #[test]
679 fn every_entry_point_judges_the_whole_request() {
680 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
681 let packer = Packer3D::default_config();
682 let empty = Error::EmptyInput {
683 parameter: "geometries".into(),
684 };
685 assert_eq!(packer.solve(&[], &boundary).expect_err("solve"), empty);
686 assert_eq!(
687 packer
688 .solve_with_progress(&[], &boundary, Box::new(|_| {}))
689 .expect_err("solve_with_progress"),
690 empty
691 );
692
693 let boxes = [Geometry3D::new("B", 10.0, 10.0, 10.0)];
694 let bad_config = Packer3D::new(Config::default().with_spacing(-1.0));
695 for err in [
696 bad_config.solve(&boxes, &boundary).expect_err("solve"),
697 bad_config
698 .solve_with_progress(&boxes, &boundary, Box::new(|_| {}))
699 .expect_err("solve_with_progress"),
700 ] {
701 assert_eq!(err.code(), "parameter_out_of_range", "{err}");
702 }
703
704 let flat = [Geometry3D::new("flat", 10.0, 0.0, 10.0)];
705 for strategy in [Strategy::ExtremePoint, Strategy::GeneticAlgorithm] {
706 let packer = Packer3D::new(Config::default().with_strategy(strategy));
707 match packer.solve(&flat, &boundary) {
708 Err(Error::OutOfRange { parameter, id, .. }) => {
709 assert_eq!(parameter, "dimensions[1]");
710 assert_eq!(id.as_deref(), Some("flat"));
711 }
712 other => panic!("{strategy:?}: {other:?}"),
713 }
714 }
715 }
716
717 #[test]
721 fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
722 let unset = Packer3D::default_config();
723 assert_eq!(unset.ga_config().population_size, 100);
724 assert_eq!(unset.ga_config().max_generations, 500);
725 assert_eq!(unset.brkga_config().population_size, 50);
726 assert_eq!(unset.brkga_config().max_generations, 100);
727
728 let set = Packer3D::new(
729 Config::default()
730 .with_population_size(12)
731 .with_max_generations(3),
732 );
733 assert_eq!(set.ga_config().population_size, 12);
734 assert_eq!(set.ga_config().max_generations, 3);
735 assert_eq!(set.brkga_config().population_size, 12);
736 assert_eq!(set.brkga_config().max_generations, 3);
737 }
738
739 #[test]
740 fn test_simple_packing() {
741 let geometries = vec![
742 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
743 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
744 ];
745
746 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
747 let packer = Packer3D::default_config();
748
749 let result = packer.solve(&geometries, &boundary).unwrap();
750
751 assert!(result.utilization > 0.0);
752 assert!(result.placements.len() <= 5);
753 }
754
755 #[test]
756 fn test_mass_constraint() {
757 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
758 .with_quantity(10)
759 .with_mass(100.0)];
760
761 let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
762
763 let packer = Packer3D::default_config();
764 let result = packer.solve(&geometries, &boundary).unwrap();
765
766 assert!(result.placements.len() <= 3);
768 }
769
770 #[test]
771 fn test_placement_within_bounds() {
772 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
773
774 let boundary = Boundary3D::new(50.0, 50.0, 50.0);
775 let config = Config::default().with_margin(5.0).with_spacing(2.0);
776 let packer = Packer3D::new(config);
777
778 let result = packer.solve(&geometries, &boundary).unwrap();
779
780 assert_eq!(result.placements.len(), 4);
782 assert!(result.unplaced.is_empty());
783
784 for p in &result.placements {
786 assert!(p.position[0] >= 5.0);
787 assert!(p.position[1] >= 5.0);
788 assert!(p.position[2] >= 5.0);
789 }
790 }
791
792 #[test]
793 fn test_ga_strategy_basic() {
794 let geometries = vec![
795 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
796 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
797 ];
798
799 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
800 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
801 let packer = Packer3D::new(config);
802
803 let result = packer.solve(&geometries, &boundary).unwrap();
804
805 assert!(result.utilization > 0.0);
807 assert!(!result.placements.is_empty());
808 }
809
810 #[test]
811 fn test_ga_strategy_all_placed() {
812 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
814
815 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
816 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
817 let packer = Packer3D::new(config);
818
819 let result = packer.solve(&geometries, &boundary).unwrap();
820
821 assert_eq!(result.placements.len(), 4);
823 assert!(result.unplaced.is_empty());
824 }
825
826 #[test]
827 #[cfg(feature = "serde")]
828 fn test_3d_response_orientation_in_bounds() {
829 use crate::build_pack3d_response;
830 use crate::geometry::OrientationConstraint;
831
832 let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
838 let boundary = Boundary3D::new(bw, bd, bh);
839
840 for strategy in [
841 Strategy::ExtremePoint,
842 Strategy::GeneticAlgorithm,
843 Strategy::Brkga,
844 ] {
845 let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
846 .with_quantity(3)
847 .with_orientation(OrientationConstraint::Any)];
848 let config = Config::default().with_strategy(strategy);
849 let packer = Packer3D::new(config);
850 let result = packer.solve(&geometries, &boundary).unwrap();
851 let response = build_pack3d_response(&result, &geometries);
852
853 if strategy == Strategy::ExtremePoint {
855 assert!(
856 !response.placements.is_empty(),
857 "EP should place the tall box by rotating it into the flat container"
858 );
859 }
860
861 let base = geometries[0].dimensions_for_orientation(0); for p in &response.placements {
863 let axes: Vec<usize> = p
864 .orientation
865 .chars()
866 .map(|c| match c {
867 'x' => 0,
868 'y' => 1,
869 'z' => 2,
870 _ => panic!("unexpected orientation label '{}'", p.orientation),
871 })
872 .collect();
873 let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
874 let e = 1e-6;
875 assert!(
876 p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
877 "{:?} {}#{} out of bounds for reported orientation '{}': \
878 pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
879 strategy,
880 p.geometry_id,
881 p.instance,
882 p.orientation,
883 p.x,
884 p.y,
885 p.z,
886 );
887 }
888 }
889 }
890
891 #[test]
892 fn test_ga_strategy_with_orientations() {
893 use crate::geometry::OrientationConstraint;
894
895 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
897 .with_quantity(2)
898 .with_orientation(OrientationConstraint::Any)];
899
900 let boundary = Boundary3D::new(60.0, 60.0, 60.0);
902 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
903 let packer = Packer3D::new(config);
904
905 let result = packer.solve(&geometries, &boundary).unwrap();
906
907 assert_eq!(result.placements.len(), 2);
909 }
910
911 #[test]
912 fn test_brkga_strategy_basic() {
913 let geometries = vec![
914 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
915 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
916 ];
917
918 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
919 let config = Config::default().with_strategy(Strategy::Brkga);
920 let packer = Packer3D::new(config);
921
922 let result = packer.solve(&geometries, &boundary).unwrap();
923
924 assert!(result.utilization > 0.0);
926 assert!(!result.placements.is_empty());
927 assert_eq!(result.strategy, Some("BRKGA".to_string()));
928 }
929
930 #[test]
931 fn test_brkga_strategy_all_placed() {
932 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
934
935 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
936 let config = Config::default().with_strategy(Strategy::Brkga);
937 let packer = Packer3D::new(config);
938
939 let result = packer.solve(&geometries, &boundary).unwrap();
940
941 assert_eq!(result.placements.len(), 4);
943 assert!(result.unplaced.is_empty());
944 }
945
946 #[test]
947 fn test_ep_strategy_basic() {
948 let geometries = vec![
949 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
950 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
951 ];
952
953 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
954 let config = Config::default().with_strategy(Strategy::ExtremePoint);
955 let packer = Packer3D::new(config);
956
957 let result = packer.solve(&geometries, &boundary).unwrap();
958
959 assert!(result.utilization > 0.0);
961 assert!(!result.placements.is_empty());
962 assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
963 }
964
965 #[test]
966 fn test_ep_strategy_all_placed() {
967 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
969
970 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
971 let config = Config::default().with_strategy(Strategy::ExtremePoint);
972 let packer = Packer3D::new(config);
973
974 let result = packer.solve(&geometries, &boundary).unwrap();
975
976 assert_eq!(result.placements.len(), 4);
978 assert!(result.unplaced.is_empty());
979 }
980
981 #[test]
982 fn test_ep_strategy_with_margin() {
983 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
984
985 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
986 let config = Config::default()
987 .with_strategy(Strategy::ExtremePoint)
988 .with_margin(5.0);
989 let packer = Packer3D::new(config);
990
991 let result = packer.solve(&geometries, &boundary).unwrap();
992
993 for p in &result.placements {
995 assert!(p.position[0] >= 4.9);
996 assert!(p.position[1] >= 4.9);
997 assert!(p.position[2] >= 4.9);
998 }
999 }
1000
1001 #[test]
1002 fn test_ep_strategy_with_orientations() {
1003 use crate::geometry::OrientationConstraint;
1004
1005 let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
1007 .with_quantity(2)
1008 .with_orientation(OrientationConstraint::Any)];
1009
1010 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1011 let config = Config::default().with_strategy(Strategy::ExtremePoint);
1012 let packer = Packer3D::new(config);
1013
1014 let result = packer.solve(&geometries, &boundary).unwrap();
1015
1016 assert_eq!(result.placements.len(), 2);
1018 }
1019
1020 #[test]
1021 fn test_ep_strategy_perfect_fill_via_solve() {
1022 let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
1026 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1027 let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
1028
1029 let result = packer.solve(&geometries, &boundary).unwrap();
1030
1031 assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
1032 assert!(result.unplaced.is_empty());
1033 }
1034
1035 #[test]
1036 fn test_ep_at_least_as_good_as_blf() {
1037 let boundary = Boundary3D::new(85.0, 85.0, 80.0);
1041 let cases: [(&str, f64, f64, f64, usize); 3] = [
1042 ("big", 40.0, 40.0, 40.0, 4),
1043 ("mid", 30.0, 20.0, 25.0, 6),
1044 ("small", 15.0, 15.0, 30.0, 8),
1045 ];
1046 let geometries: Vec<Geometry3D> = cases
1047 .iter()
1048 .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
1049 .collect();
1050
1051 let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
1052 .solve(&geometries, &boundary)
1053 .unwrap();
1054 let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
1055 .solve(&geometries, &boundary)
1056 .unwrap();
1057
1058 assert!(
1059 ep.placements.len() >= blf.placements.len(),
1060 "EP placed {} but BLF placed {} — EP must not regress below BLF",
1061 ep.placements.len(),
1062 blf.placements.len()
1063 );
1064 }
1065
1066 fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
1068 let geometries = vec![
1069 Geometry3D::new("a", 20.0, 20.0, 20.0),
1070 Geometry3D::new("b", 20.0, 20.0, 20.0),
1071 ];
1072 let mut result = SolveResult::new();
1073 result.placements.push(
1074 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1075 .with_rotation_index(0),
1076 );
1077 result.placements.push(
1079 Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1080 .with_rotation_index(0),
1081 );
1082 (geometries, result)
1083 }
1084
1085 #[test]
1086 fn test_gravity_removes_floating_box() {
1087 let (geometries, mut result) = floating_pair();
1088 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1089 let packer = Packer3D::default_config();
1090
1091 packer.enforce_support(&mut result, &geometries, &boundary);
1092
1093 let ids: Vec<&str> = result
1094 .placements
1095 .iter()
1096 .map(|p| p.geometry_id.as_str())
1097 .collect();
1098 assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1099 assert!(result.unplaced.iter().any(|id| id == "b"));
1100 }
1101
1102 #[test]
1103 fn test_no_constraint_keeps_floating_box() {
1104 let (geometries, result) = floating_pair();
1108 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1109 assert!(!boundary.has_gravity() && !boundary.has_stability());
1110 assert_eq!(result.placements.len(), 2);
1112 let _ = geometries;
1113 }
1114
1115 #[test]
1116 fn test_gravity_keeps_floor_boxes_with_margin() {
1117 let geometries = vec![
1122 Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1123 Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1124 ];
1125 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1126 let packer = Packer3D::new(
1127 Config::default()
1128 .with_margin(5.0)
1129 .with_strategy(Strategy::BottomLeftFill),
1130 );
1131
1132 let result = packer.solve(&geometries, &boundary).unwrap();
1133
1134 assert!(
1135 !result.placements.is_empty(),
1136 "margin>0 + gravity must not drop floor-resting boxes"
1137 );
1138 }
1139
1140 #[test]
1141 fn test_stability_drops_undersupported_box() {
1142 let geometries = vec![
1146 Geometry3D::new("a", 40.0, 40.0, 20.0),
1147 Geometry3D::new("b", 40.0, 40.0, 20.0),
1148 ];
1149 let make = || {
1150 let mut r = SolveResult::new();
1151 r.placements.push(
1152 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1153 .with_rotation_index(0),
1154 );
1155 r.placements.push(
1157 Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1158 .with_rotation_index(0),
1159 );
1160 r
1161 };
1162 let packer = Packer3D::default_config();
1163
1164 let mut grav = make();
1165 packer.enforce_support(
1166 &mut grav,
1167 &geometries,
1168 &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1169 );
1170 assert_eq!(
1171 grav.placements.len(),
1172 2,
1173 "gravity keeps a box that touches below"
1174 );
1175
1176 let mut stab = make();
1177 packer.enforce_support(
1178 &mut stab,
1179 &geometries,
1180 &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1181 );
1182 assert!(
1183 stab.placements.iter().all(|p| p.geometry_id == "a"),
1184 "stability drops the under-supported box"
1185 );
1186 }
1187
1188 #[test]
1189 fn test_layer_packing_orientation_optimization() {
1190 use crate::geometry::OrientationConstraint;
1191
1192 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1195 .with_quantity(2)
1196 .with_orientation(OrientationConstraint::Any)];
1197
1198 let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1200 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1201 let packer = Packer3D::new(config);
1202
1203 let result = packer.solve(&geometries, &boundary).unwrap();
1204
1205 assert_eq!(
1207 result.placements.len(),
1208 2,
1209 "Both boxes should be placed by using rotation"
1210 );
1211 assert!(result.unplaced.is_empty());
1212
1213 for p in &result.placements {
1215 assert!(
1216 p.rotation_index.is_some(),
1217 "Placement should have rotation_index set"
1218 );
1219 }
1220 }
1221
1222 #[test]
1223 fn test_layer_packing_selects_best_orientation() {
1224 use crate::geometry::OrientationConstraint;
1225
1226 let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1231 .with_quantity(1)
1232 .with_orientation(OrientationConstraint::Any)];
1233
1234 let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1235 let packer = Packer3D::default_config();
1236
1237 let result = packer.solve(&geometries, &boundary).unwrap();
1238
1239 assert_eq!(result.placements.len(), 1);
1240 assert!(result.unplaced.is_empty());
1241 }
1242}