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};
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(
53 &self,
54 geometries: &[Geometry3D],
55 boundary: &Boundary3D,
56 ) -> Result<SolveResult<f64>> {
57 match self.config.strategy {
58 Strategy::BottomLeftFill => self.layer_packing(geometries, boundary),
59 Strategy::ExtremePoint => self.extreme_point(geometries, boundary),
60 Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
61 Strategy::Brkga => self.brkga(geometries, boundary),
62 Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
63 other => Err(Error::invalid_option(
64 "strategy",
65 format!(
66 "strategy \"{}\" is not available for 3D packing; it offers {}",
67 other.name(),
68 offered_names(Dimension::ThreeD)
69 ),
70 )),
71 }
72 }
73
74 pub fn new(config: Config) -> Self {
76 Self {
77 config,
78 cancelled: Arc::new(AtomicBool::new(false)),
79 }
80 }
81
82 pub fn default_config() -> Self {
84 Self::new(Config::default())
85 }
86
87 fn ga_config(&self) -> GaConfig {
91 let config = GaConfig::default()
92 .with_population_size(self.config.population_size.unwrap_or(GA_POPULATION))
93 .with_max_generations(self.config.max_generations.unwrap_or(GA_GENERATIONS))
94 .with_crossover_rate(self.config.crossover_rate)
95 .with_mutation_rate(self.config.mutation_rate)
96 .with_elite_count(self.config.elite_count);
97 match self.target_fitness() {
98 Some(target) => config.with_target_fitness(target),
99 None => config,
100 }
101 }
102
103 fn target_fitness(&self) -> Option<f64> {
106 self.config
107 .target_utilization
108 .map(|target| packing_fitness(1, 1, target))
109 }
110
111 fn brkga_config(&self) -> BrkgaConfig {
115 let config = BrkgaConfig::default()
116 .with_population_size(self.config.population_size.unwrap_or(BRKGA_POPULATION))
117 .with_max_generations(self.config.max_generations.unwrap_or(BRKGA_GENERATIONS))
118 .with_elite_fraction(0.2)
119 .with_mutant_fraction(0.15)
120 .with_elite_bias(0.7);
121 match self.target_fitness() {
122 Some(target) => config.with_target_fitness(target),
123 None => config,
124 }
125 }
126
127 fn validate_inputs(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<()> {
131 boundary.validate()?;
132 if geometries.is_empty() {
133 return Err(Error::empty_input("geometries"));
134 }
135 self.config.validate()?;
136 for geom in geometries {
137 geom.validate()?;
138 }
139 u_nesting_core::geometry::ensure_unique_ids(geometries)
140 }
141
142 pub fn validate_stability(
150 &self,
151 result: &SolveResult<f64>,
152 geometries: &[Geometry3D],
153 _boundary: &Boundary3D,
154 constraint: StabilityConstraint,
155 ) -> Result<StabilityReport> {
156 let placed_boxes = self.placements_to_boxes(result, geometries);
158 let analyzer = StabilityAnalyzer::new(constraint)?;
159 Ok(analyzer.analyze(&placed_boxes, 0.0))
160 }
161
162 pub fn validate_stability_physics(
166 &self,
167 result: &SolveResult<f64>,
168 geometries: &[Geometry3D],
169 boundary: &Boundary3D,
170 ) -> StabilityReport {
171 let placed_boxes = self.placements_to_boxes(result, geometries);
172 let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
173
174 let config = PhysicsConfig::default().with_max_time(2.0);
175 let simulator =
176 PhysicsSimulator::new(config).expect("the default physics settings are in range");
177 simulator.validate_stability(&placed_boxes, container, 0.0)
178 }
179
180 fn enforce_support(
191 &self,
192 result: &mut SolveResult<f64>,
193 geometries: &[Geometry3D],
194 boundary: &Boundary3D,
195 ) {
196 let constraint = if boundary.has_stability() {
199 StabilityConstraint::partial_base(0.7)
200 } else {
201 StabilityConstraint::partial_base(1e-6)
202 };
203
204 let floor_z = self.config.margin;
208 let analyzer = StabilityAnalyzer::new(constraint)
209 .expect("the support ratios used here, 0.7 and 1e-6, are in range");
210 while !result.placements.is_empty() {
211 let placed_boxes = self.placements_to_boxes(result, geometries);
212 let report = analyzer.analyze(&placed_boxes, floor_z);
213 if report.is_all_stable() {
214 break;
215 }
216 let drop: std::collections::HashSet<(String, usize)> = report
217 .unstable_boxes()
218 .iter()
219 .map(|r| (r.id.clone(), r.instance))
220 .collect();
221 result
222 .placements
223 .retain(|p| !drop.contains(&(p.geometry_id.clone(), p.instance)));
224 for (id, _) in drop {
225 result.unplaced.push(id);
226 }
227 }
228
229 result.deduplicate_unplaced();
230 let boxes = self.placements_to_boxes(result, geometries);
232 let placed_volume: f64 = boxes
233 .iter()
234 .map(|b| b.dimensions.x * b.dimensions.y * b.dimensions.z)
235 .sum();
236 let container_volume = boundary.measure();
237 result.utilization = if container_volume > 0.0 {
238 placed_volume / container_volume
239 } else {
240 0.0
241 };
242 }
243
244 fn placements_to_boxes(
246 &self,
247 result: &SolveResult<f64>,
248 geometries: &[Geometry3D],
249 ) -> Vec<PlacedBox> {
250 let geom_map: std::collections::HashMap<&str, &Geometry3D> =
251 geometries.iter().map(|g| (g.id().as_str(), g)).collect();
252
253 result
254 .placements
255 .iter()
256 .filter_map(|p| {
257 let geom = geom_map.get(p.geometry_id.as_str())?;
258 let ori_idx = p.rotation_index.unwrap_or(0);
259 let dims = geom.dimensions_for_orientation(ori_idx);
260
261 let mut placed = PlacedBox::new(
262 p.geometry_id.clone(),
263 p.instance,
264 Point3::new(p.position[0], p.position[1], p.position[2]),
265 dims,
266 );
267
268 if let Some(mass) = geom.mass() {
269 placed = placed.with_mass(mass);
270 }
271
272 Some(placed)
273 })
274 .collect()
275 }
276
277 fn layer_packing(
280 &self,
281 geometries: &[Geometry3D],
282 boundary: &Boundary3D,
283 ) -> Result<SolveResult<f64>> {
284 Ok(self.layer_fill(geometries, boundary, None))
285 }
286
287 fn layer_fill(
290 &self,
291 geometries: &[Geometry3D],
292 boundary: &Boundary3D,
293 callback: Option<&ProgressCallback>,
294 ) -> SolveResult<f64> {
295 let start = Timer::now();
296 let total: usize = geometries.iter().map(|g| g.quantity()).sum();
297 let report = |info: ProgressInfo| {
298 if let Some(callback) = callback {
299 callback(info);
300 }
301 };
302 report(
303 ProgressInfo::new()
304 .with_phase("Layer Packing")
305 .with_items(0, total)
306 .with_elapsed(0),
307 );
308
309 let mut cancelled = false;
310 let mut timed_out = false;
311 let (fill, _stopped) = layer_fill_in_order(
312 geometries,
313 boundary,
314 &self.config,
315 || {
316 cancelled = self.cancelled.load(Ordering::Relaxed);
317 timed_out = self.config.time_limit_ms > 0
318 && start.elapsed_ms() >= self.config.time_limit_ms;
319 cancelled || timed_out
320 },
321 |fill| {
322 report(
323 ProgressInfo::new()
324 .with_phase("Layer Packing")
325 .with_items(fill.placed(), total)
326 .with_utilization(fill.utilization())
327 .with_elapsed(start.elapsed_ms()),
328 )
329 },
330 );
331
332 let mut result = SolveResult::new();
333 result.unplaced = build_unplaced_list(&fill.placements, geometries);
334 result.boundaries_used = usize::from(!fill.placements.is_empty());
335 result.utilization = fill.utilization;
336 result.placements = fill.placements;
337 result.cancelled = cancelled;
338 result.computation_time_ms = start.elapsed_ms();
339 let phase = if cancelled {
340 "Cancelled"
341 } else if timed_out {
342 "Time Limit Reached"
343 } else {
344 "Complete"
345 };
346 report(
347 ProgressInfo::new()
348 .with_phase(phase)
349 .with_items(result.placements.len(), total)
350 .with_utilization(result.utilization)
351 .with_elapsed(result.computation_time_ms)
352 .finished(),
353 );
354 result
355 }
356
357 fn search_time_limit(&self, started: &Timer) -> Option<std::time::Duration> {
360 (self.config.time_limit_ms > 0).then(|| {
361 std::time::Duration::from_millis(
362 self.config
363 .time_limit_ms
364 .saturating_sub(started.elapsed_ms())
365 .max(1),
366 )
367 })
368 }
369
370 fn not_worse_than_fills(
380 &self,
381 search: SolveResult<f64>,
382 geometries: &[Geometry3D],
383 boundary: &Boundary3D,
384 layer_fill: SolveResult<f64>,
385 ) -> SolveResult<f64> {
386 let supported = |mut r: SolveResult<f64>| {
387 if boundary.has_gravity() || boundary.has_stability() {
388 self.enforce_support(&mut r, geometries, boundary);
389 }
390 r
391 };
392 let fills = [
393 Some(layer_fill),
394 self.extreme_point(geometries, boundary).ok(),
395 ]
396 .into_iter()
397 .flatten()
398 .map(supported);
399 supported(search).floored_at(fills, |a, b| {
400 let (pa, pb) = (a.placements.len(), b.placements.len());
401 pa > pb || (pa == pb && a.utilization > b.utilization + 1e-9)
402 })
403 }
404
405 fn genetic_algorithm(
410 &self,
411 geometries: &[Geometry3D],
412 boundary: &Boundary3D,
413 ) -> Result<SolveResult<f64>> {
414 let started = Timer::now();
415 let fill = self.layer_fill(geometries, boundary, None);
416 let mut ga_config = self.ga_config();
417 ga_config.time_limit = self.search_time_limit(&started);
418 let result = run_ga_packing(
419 geometries,
420 boundary,
421 &self.config,
422 ga_config,
423 self.cancelled.clone(),
424 Some(&fill.placements),
425 )?;
426 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
427 }
428
429 fn brkga(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<SolveResult<f64>> {
434 let started = Timer::now();
435 let fill = self.layer_fill(geometries, boundary, None);
436 let mut brkga_config = self.brkga_config();
437 brkga_config.time_limit = self.search_time_limit(&started);
438 let result = run_brkga_packing(
439 geometries,
440 boundary,
441 &self.config,
442 brkga_config,
443 self.cancelled.clone(),
444 Some(&fill.placements),
445 )?;
446 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
447 }
448
449 fn simulated_annealing(
454 &self,
455 geometries: &[Geometry3D],
456 boundary: &Boundary3D,
457 ) -> Result<SolveResult<f64>> {
458 let started = Timer::now();
459 let fill = self.layer_fill(geometries, boundary, None);
460 let mut sa_config = SaConfig::default()
461 .with_initial_temp(100.0)
462 .with_final_temp(0.1)
463 .with_cooling_rate(0.95)
464 .with_iterations_per_temp(50)
465 .with_max_iterations(10000);
466 sa_config.time_limit = self.search_time_limit(&started);
467 sa_config.target_fitness = self.target_fitness();
468
469 let result = run_sa_packing(
470 geometries,
471 boundary,
472 &self.config,
473 sa_config,
474 self.cancelled.clone(),
475 Some(&fill.placements),
476 )?;
477 Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
478 }
479
480 fn extreme_point(
485 &self,
486 geometries: &[Geometry3D],
487 boundary: &Boundary3D,
488 ) -> Result<SolveResult<f64>> {
489 let start = Timer::now();
490
491 let mut cancelled = false;
492 let (ep_placements, utilization) = run_ep_packing(
493 geometries,
494 boundary,
495 self.config.margin,
496 self.config.spacing,
497 boundary.max_mass(),
498 || {
499 cancelled = self.cancelled.load(Ordering::Relaxed);
500 cancelled
501 || (self.config.time_limit_ms > 0
502 && start.elapsed_ms() >= self.config.time_limit_ms)
503 },
504 );
505
506 let mut placements = Vec::new();
511 for (id, instance, position, orientation) in ep_placements {
512 let placement = Placement::new_3d(
513 id, instance, position.x, position.y, position.z, 0.0, 0.0, 0.0, )
517 .with_rotation_index(orientation);
518 placements.push(placement);
519 }
520
521 let unplaced = build_unplaced_list(&placements, geometries);
522
523 let mut result = SolveResult::new();
524 result.boundaries_used = usize::from(!placements.is_empty());
525 result.placements = placements;
526 result.utilization = utilization;
527 result.cancelled = cancelled;
528 result.unplaced = unplaced;
529 result.computation_time_ms = start.elapsed_ms();
530 result.strategy = Some("ExtremePoint".to_string());
531
532 Ok(result)
533 }
534}
535
536impl Solver for Packer3D {
537 type Geometry = Geometry3D;
538 type Boundary = Boundary3D;
539 type Scalar = f64;
540
541 fn solve(
542 &self,
543 geometries: &[Self::Geometry],
544 boundary: &Self::Boundary,
545 ) -> Result<SolveResult<f64>> {
546 self.validate_inputs(geometries, boundary)?;
547
548 self.cancelled.store(false, Ordering::Relaxed);
550
551 let mut result = self.run_strategy(geometries, boundary)?;
552
553 result.deduplicate_unplaced();
555 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
558
559 if boundary.has_gravity() || boundary.has_stability() {
561 self.enforce_support(&mut result, geometries, boundary);
562 }
563 Ok(result)
564 }
565
566 fn solve_with_progress(
567 &self,
568 geometries: &[Self::Geometry],
569 boundary: &Self::Boundary,
570 callback: ProgressCallback,
571 ) -> Result<SolveResult<f64>> {
572 self.validate_inputs(geometries, boundary)?;
573
574 self.cancelled.store(false, Ordering::Relaxed);
576
577 let mut result = match self.config.strategy {
578 Strategy::BottomLeftFill => self.layer_fill(geometries, boundary, Some(&callback)),
579 Strategy::ExtremePoint => self.extreme_point(geometries, boundary)?,
582 _ => self.run_strategy(geometries, boundary)?,
586 };
587
588 result.deduplicate_unplaced();
590 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
593
594 if boundary.has_gravity() || boundary.has_stability() {
596 self.enforce_support(&mut result, geometries, boundary);
597 }
598 Ok(result)
599 }
600
601 fn cancel(&self) {
602 self.cancelled.store(true, Ordering::Relaxed);
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
612 let geometries = [
613 Geometry3D::new("B", 10.0, 10.0, 10.0),
614 Geometry3D::new("B", 50.0, 50.0, 50.0),
615 ];
616 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
617 let packer = Packer3D::default_config();
618 let errors = [
619 packer.solve(&geometries, &boundary).expect_err("solve"),
620 packer
621 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
622 .expect_err("solve_with_progress"),
623 ];
624 for err in errors {
625 let msg = err.to_string();
626 assert!(msg.contains("'B'"), "{msg}");
627 assert!(msg.contains("positions 0 and 1"), "{msg}");
628 }
629 }
630
631 #[test]
636 fn every_entry_point_judges_the_whole_request() {
637 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
638 let packer = Packer3D::default_config();
639 let empty = Error::EmptyInput {
640 parameter: "geometries".into(),
641 };
642 assert_eq!(packer.solve(&[], &boundary).expect_err("solve"), empty);
643 assert_eq!(
644 packer
645 .solve_with_progress(&[], &boundary, Box::new(|_| {}))
646 .expect_err("solve_with_progress"),
647 empty
648 );
649
650 let boxes = [Geometry3D::new("B", 10.0, 10.0, 10.0)];
651 let bad_config = Packer3D::new(Config::default().with_spacing(-1.0));
652 for err in [
653 bad_config.solve(&boxes, &boundary).expect_err("solve"),
654 bad_config
655 .solve_with_progress(&boxes, &boundary, Box::new(|_| {}))
656 .expect_err("solve_with_progress"),
657 ] {
658 assert_eq!(err.code(), "parameter_out_of_range", "{err}");
659 }
660
661 let flat = [Geometry3D::new("flat", 10.0, 0.0, 10.0)];
662 for strategy in [Strategy::ExtremePoint, Strategy::GeneticAlgorithm] {
663 let packer = Packer3D::new(Config::default().with_strategy(strategy));
664 match packer.solve(&flat, &boundary) {
665 Err(Error::OutOfRange { parameter, id, .. }) => {
666 assert_eq!(parameter, "dimensions[1]");
667 assert_eq!(id.as_deref(), Some("flat"));
668 }
669 other => panic!("{strategy:?}: {other:?}"),
670 }
671 }
672 }
673
674 #[test]
678 fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
679 let unset = Packer3D::default_config();
680 assert_eq!(unset.ga_config().population_size, 100);
681 assert_eq!(unset.ga_config().max_generations, 500);
682 assert_eq!(unset.brkga_config().population_size, 50);
683 assert_eq!(unset.brkga_config().max_generations, 100);
684
685 let set = Packer3D::new(
686 Config::default()
687 .with_population_size(12)
688 .with_max_generations(3),
689 );
690 assert_eq!(set.ga_config().population_size, 12);
691 assert_eq!(set.ga_config().max_generations, 3);
692 assert_eq!(set.brkga_config().population_size, 12);
693 assert_eq!(set.brkga_config().max_generations, 3);
694 }
695
696 #[test]
697 fn test_simple_packing() {
698 let geometries = vec![
699 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
700 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
701 ];
702
703 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
704 let packer = Packer3D::default_config();
705
706 let result = packer.solve(&geometries, &boundary).unwrap();
707
708 assert!(result.utilization > 0.0);
709 assert!(result.placements.len() <= 5);
710 }
711
712 #[test]
713 fn test_mass_constraint() {
714 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
715 .with_quantity(10)
716 .with_mass(100.0)];
717
718 let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
719
720 let packer = Packer3D::default_config();
721 let result = packer.solve(&geometries, &boundary).unwrap();
722
723 assert!(result.placements.len() <= 3);
725 }
726
727 #[test]
728 fn test_placement_within_bounds() {
729 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
730
731 let boundary = Boundary3D::new(50.0, 50.0, 50.0);
732 let config = Config::default().with_margin(5.0).with_spacing(2.0);
733 let packer = Packer3D::new(config);
734
735 let result = packer.solve(&geometries, &boundary).unwrap();
736
737 assert_eq!(result.placements.len(), 4);
739 assert!(result.unplaced.is_empty());
740
741 for p in &result.placements {
743 assert!(p.position[0] >= 5.0);
744 assert!(p.position[1] >= 5.0);
745 assert!(p.position[2] >= 5.0);
746 }
747 }
748
749 #[test]
750 fn test_ga_strategy_basic() {
751 let geometries = vec![
752 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
753 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
754 ];
755
756 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
757 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
758 let packer = Packer3D::new(config);
759
760 let result = packer.solve(&geometries, &boundary).unwrap();
761
762 assert!(result.utilization > 0.0);
764 assert!(!result.placements.is_empty());
765 }
766
767 #[test]
768 fn test_ga_strategy_all_placed() {
769 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
771
772 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
773 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
774 let packer = Packer3D::new(config);
775
776 let result = packer.solve(&geometries, &boundary).unwrap();
777
778 assert_eq!(result.placements.len(), 4);
780 assert!(result.unplaced.is_empty());
781 }
782
783 #[test]
784 #[cfg(feature = "serde")]
785 fn test_3d_response_orientation_in_bounds() {
786 use crate::build_pack3d_response;
787 use crate::geometry::OrientationConstraint;
788
789 let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
795 let boundary = Boundary3D::new(bw, bd, bh);
796
797 for strategy in [
798 Strategy::ExtremePoint,
799 Strategy::GeneticAlgorithm,
800 Strategy::Brkga,
801 ] {
802 let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
803 .with_quantity(3)
804 .with_orientation(OrientationConstraint::Any)];
805 let config = Config::default().with_strategy(strategy);
806 let packer = Packer3D::new(config);
807 let result = packer.solve(&geometries, &boundary).unwrap();
808 let response = build_pack3d_response(&result, &geometries);
809
810 if strategy == Strategy::ExtremePoint {
812 assert!(
813 !response.placements.is_empty(),
814 "EP should place the tall box by rotating it into the flat container"
815 );
816 }
817
818 let base = geometries[0].dimensions_for_orientation(0); for p in &response.placements {
820 let axes: Vec<usize> = p
821 .orientation
822 .chars()
823 .map(|c| match c {
824 'x' => 0,
825 'y' => 1,
826 'z' => 2,
827 _ => panic!("unexpected orientation label '{}'", p.orientation),
828 })
829 .collect();
830 let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
831 let e = 1e-6;
832 assert!(
833 p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
834 "{:?} {}#{} out of bounds for reported orientation '{}': \
835 pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
836 strategy,
837 p.geometry_id,
838 p.instance,
839 p.orientation,
840 p.x,
841 p.y,
842 p.z,
843 );
844 }
845 }
846 }
847
848 #[test]
849 fn test_ga_strategy_with_orientations() {
850 use crate::geometry::OrientationConstraint;
851
852 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
854 .with_quantity(2)
855 .with_orientation(OrientationConstraint::Any)];
856
857 let boundary = Boundary3D::new(60.0, 60.0, 60.0);
859 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
860 let packer = Packer3D::new(config);
861
862 let result = packer.solve(&geometries, &boundary).unwrap();
863
864 assert_eq!(result.placements.len(), 2);
866 }
867
868 #[test]
869 fn test_brkga_strategy_basic() {
870 let geometries = vec![
871 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
872 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
873 ];
874
875 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
876 let config = Config::default().with_strategy(Strategy::Brkga);
877 let packer = Packer3D::new(config);
878
879 let result = packer.solve(&geometries, &boundary).unwrap();
880
881 assert!(result.utilization > 0.0);
883 assert!(!result.placements.is_empty());
884 assert_eq!(result.strategy, Some("BRKGA".to_string()));
885 }
886
887 #[test]
888 fn test_brkga_strategy_all_placed() {
889 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
891
892 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
893 let config = Config::default().with_strategy(Strategy::Brkga);
894 let packer = Packer3D::new(config);
895
896 let result = packer.solve(&geometries, &boundary).unwrap();
897
898 assert_eq!(result.placements.len(), 4);
900 assert!(result.unplaced.is_empty());
901 }
902
903 #[test]
904 fn test_ep_strategy_basic() {
905 let geometries = vec![
906 Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
907 Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
908 ];
909
910 let boundary = Boundary3D::new(100.0, 80.0, 50.0);
911 let config = Config::default().with_strategy(Strategy::ExtremePoint);
912 let packer = Packer3D::new(config);
913
914 let result = packer.solve(&geometries, &boundary).unwrap();
915
916 assert!(result.utilization > 0.0);
918 assert!(!result.placements.is_empty());
919 assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
920 }
921
922 #[test]
923 fn test_ep_strategy_all_placed() {
924 let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
926
927 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
928 let config = Config::default().with_strategy(Strategy::ExtremePoint);
929 let packer = Packer3D::new(config);
930
931 let result = packer.solve(&geometries, &boundary).unwrap();
932
933 assert_eq!(result.placements.len(), 4);
935 assert!(result.unplaced.is_empty());
936 }
937
938 #[test]
939 fn test_ep_strategy_with_margin() {
940 let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
941
942 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
943 let config = Config::default()
944 .with_strategy(Strategy::ExtremePoint)
945 .with_margin(5.0);
946 let packer = Packer3D::new(config);
947
948 let result = packer.solve(&geometries, &boundary).unwrap();
949
950 for p in &result.placements {
952 assert!(p.position[0] >= 4.9);
953 assert!(p.position[1] >= 4.9);
954 assert!(p.position[2] >= 4.9);
955 }
956 }
957
958 #[test]
959 fn test_ep_strategy_with_orientations() {
960 use crate::geometry::OrientationConstraint;
961
962 let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
964 .with_quantity(2)
965 .with_orientation(OrientationConstraint::Any)];
966
967 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
968 let config = Config::default().with_strategy(Strategy::ExtremePoint);
969 let packer = Packer3D::new(config);
970
971 let result = packer.solve(&geometries, &boundary).unwrap();
972
973 assert_eq!(result.placements.len(), 2);
975 }
976
977 #[test]
978 fn test_ep_strategy_perfect_fill_via_solve() {
979 let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
983 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
984 let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
985
986 let result = packer.solve(&geometries, &boundary).unwrap();
987
988 assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
989 assert!(result.unplaced.is_empty());
990 }
991
992 #[test]
993 fn test_ep_at_least_as_good_as_blf() {
994 let boundary = Boundary3D::new(85.0, 85.0, 80.0);
998 let cases: [(&str, f64, f64, f64, usize); 3] = [
999 ("big", 40.0, 40.0, 40.0, 4),
1000 ("mid", 30.0, 20.0, 25.0, 6),
1001 ("small", 15.0, 15.0, 30.0, 8),
1002 ];
1003 let geometries: Vec<Geometry3D> = cases
1004 .iter()
1005 .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
1006 .collect();
1007
1008 let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
1009 .solve(&geometries, &boundary)
1010 .unwrap();
1011 let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
1012 .solve(&geometries, &boundary)
1013 .unwrap();
1014
1015 assert!(
1016 ep.placements.len() >= blf.placements.len(),
1017 "EP placed {} but BLF placed {} — EP must not regress below BLF",
1018 ep.placements.len(),
1019 blf.placements.len()
1020 );
1021 }
1022
1023 fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
1025 let geometries = vec![
1026 Geometry3D::new("a", 20.0, 20.0, 20.0),
1027 Geometry3D::new("b", 20.0, 20.0, 20.0),
1028 ];
1029 let mut result = SolveResult::new();
1030 result.placements.push(
1031 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1032 .with_rotation_index(0),
1033 );
1034 result.placements.push(
1036 Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1037 .with_rotation_index(0),
1038 );
1039 (geometries, result)
1040 }
1041
1042 #[test]
1043 fn test_gravity_removes_floating_box() {
1044 let (geometries, mut result) = floating_pair();
1045 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1046 let packer = Packer3D::default_config();
1047
1048 packer.enforce_support(&mut result, &geometries, &boundary);
1049
1050 let ids: Vec<&str> = result
1051 .placements
1052 .iter()
1053 .map(|p| p.geometry_id.as_str())
1054 .collect();
1055 assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1056 assert!(result.unplaced.iter().any(|id| id == "b"));
1057 }
1058
1059 #[test]
1060 fn test_no_constraint_keeps_floating_box() {
1061 let (geometries, result) = floating_pair();
1065 let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1066 assert!(!boundary.has_gravity() && !boundary.has_stability());
1067 assert_eq!(result.placements.len(), 2);
1069 let _ = geometries;
1070 }
1071
1072 #[test]
1073 fn test_gravity_keeps_floor_boxes_with_margin() {
1074 let geometries = vec![
1079 Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1080 Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1081 ];
1082 let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1083 let packer = Packer3D::new(
1084 Config::default()
1085 .with_margin(5.0)
1086 .with_strategy(Strategy::BottomLeftFill),
1087 );
1088
1089 let result = packer.solve(&geometries, &boundary).unwrap();
1090
1091 assert!(
1092 !result.placements.is_empty(),
1093 "margin>0 + gravity must not drop floor-resting boxes"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_stability_drops_undersupported_box() {
1099 let geometries = vec![
1103 Geometry3D::new("a", 40.0, 40.0, 20.0),
1104 Geometry3D::new("b", 40.0, 40.0, 20.0),
1105 ];
1106 let make = || {
1107 let mut r = SolveResult::new();
1108 r.placements.push(
1109 Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1110 .with_rotation_index(0),
1111 );
1112 r.placements.push(
1114 Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1115 .with_rotation_index(0),
1116 );
1117 r
1118 };
1119 let packer = Packer3D::default_config();
1120
1121 let mut grav = make();
1122 packer.enforce_support(
1123 &mut grav,
1124 &geometries,
1125 &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1126 );
1127 assert_eq!(
1128 grav.placements.len(),
1129 2,
1130 "gravity keeps a box that touches below"
1131 );
1132
1133 let mut stab = make();
1134 packer.enforce_support(
1135 &mut stab,
1136 &geometries,
1137 &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1138 );
1139 assert!(
1140 stab.placements.iter().all(|p| p.geometry_id == "a"),
1141 "stability drops the under-supported box"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_layer_packing_orientation_optimization() {
1147 use crate::geometry::OrientationConstraint;
1148
1149 let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1152 .with_quantity(2)
1153 .with_orientation(OrientationConstraint::Any)];
1154
1155 let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1157 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1158 let packer = Packer3D::new(config);
1159
1160 let result = packer.solve(&geometries, &boundary).unwrap();
1161
1162 assert_eq!(
1164 result.placements.len(),
1165 2,
1166 "Both boxes should be placed by using rotation"
1167 );
1168 assert!(result.unplaced.is_empty());
1169
1170 for p in &result.placements {
1172 assert!(
1173 p.rotation_index.is_some(),
1174 "Placement should have rotation_index set"
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn test_layer_packing_selects_best_orientation() {
1181 use crate::geometry::OrientationConstraint;
1182
1183 let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1188 .with_quantity(1)
1189 .with_orientation(OrientationConstraint::Any)];
1190
1191 let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1192 let packer = Packer3D::default_config();
1193
1194 let result = packer.solve(&geometries, &boundary).unwrap();
1195
1196 assert_eq!(result.placements.len(), 1);
1197 assert!(result.unplaced.is_empty());
1198 }
1199}