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