u_nesting_d3/
packer.rs

1//! 3D bin packing solver.
2
3use crate::boundary::Boundary3D;
4use crate::brkga_packing::run_brkga_packing;
5use crate::extreme_point::run_ep_packing;
6use crate::ga_packing::run_ga_packing;
7use crate::geometry::Geometry3D;
8use crate::physics::{PhysicsConfig, PhysicsSimulator};
9use crate::sa_packing::run_sa_packing;
10use crate::stability::{PlacedBox, StabilityAnalyzer, StabilityConstraint, StabilityReport};
11use nalgebra::{Point3, Vector3};
12use u_nesting_core::brkga::BrkgaConfig;
13use u_nesting_core::ga::GaConfig;
14use u_nesting_core::geometry::{Boundary, Geometry};
15use u_nesting_core::sa::SaConfig;
16use u_nesting_core::solver::{Config, ProgressCallback, ProgressInfo, Solver, Strategy};
17use u_nesting_core::{Placement, Result, SolveResult};
18
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use std::time::Instant;
22
23/// Best fit candidate for 3D placement.
24/// (orientation_idx, width, depth, height, place_x, place_y, place_z, new_row_depth, new_layer_height)
25type BestFit3D = Option<(usize, f64, f64, f64, f64, f64, f64, f64, f64)>;
26
27/// 3D bin packing solver.
28pub struct Packer3D {
29    config: Config,
30    cancelled: Arc<AtomicBool>,
31}
32
33impl Packer3D {
34    /// Creates a new packer with the given configuration.
35    pub fn new(config: Config) -> Self {
36        Self {
37            config,
38            cancelled: Arc::new(AtomicBool::new(false)),
39        }
40    }
41
42    /// Creates a packer with default configuration.
43    pub fn default_config() -> Self {
44        Self::new(Config::default())
45    }
46
47    /// Validates the stability of a packing result.
48    ///
49    /// Analyzes each placement to ensure boxes are properly supported.
50    pub fn validate_stability(
51        &self,
52        result: &SolveResult<f64>,
53        geometries: &[Geometry3D],
54        _boundary: &Boundary3D,
55        constraint: StabilityConstraint,
56    ) -> StabilityReport {
57        // Convert placements to PlacedBox format
58        let placed_boxes = self.placements_to_boxes(result, geometries);
59        let analyzer = StabilityAnalyzer::new(constraint);
60        analyzer.analyze(&placed_boxes, 0.0)
61    }
62
63    /// Validates stability using physics simulation.
64    ///
65    /// Runs a physics simulation to detect boxes that would fall or tip.
66    pub fn validate_stability_physics(
67        &self,
68        result: &SolveResult<f64>,
69        geometries: &[Geometry3D],
70        boundary: &Boundary3D,
71    ) -> StabilityReport {
72        let placed_boxes = self.placements_to_boxes(result, geometries);
73        let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
74
75        let config = PhysicsConfig::default().with_max_time(2.0);
76        let simulator = PhysicsSimulator::new(config);
77        simulator.validate_stability(&placed_boxes, container, 0.0)
78    }
79
80    /// Converts placements to PlacedBox format for stability analysis.
81    fn placements_to_boxes(
82        &self,
83        result: &SolveResult<f64>,
84        geometries: &[Geometry3D],
85    ) -> Vec<PlacedBox> {
86        let geom_map: std::collections::HashMap<&str, &Geometry3D> =
87            geometries.iter().map(|g| (g.id().as_str(), g)).collect();
88
89        result
90            .placements
91            .iter()
92            .filter_map(|p| {
93                let geom = geom_map.get(p.geometry_id.as_str())?;
94                let ori_idx = p.rotation_index.unwrap_or(0);
95                let dims = geom.dimensions_for_orientation(ori_idx);
96
97                let mut placed = PlacedBox::new(
98                    p.geometry_id.clone(),
99                    p.instance,
100                    Point3::new(p.position[0], p.position[1], p.position[2]),
101                    dims,
102                );
103
104                if let Some(mass) = geom.mass() {
105                    placed = placed.with_mass(mass);
106                }
107
108                Some(placed)
109            })
110            .collect()
111    }
112
113    /// Simple layer-based packing algorithm.
114    fn layer_packing(
115        &self,
116        geometries: &[Geometry3D],
117        boundary: &Boundary3D,
118    ) -> Result<SolveResult<f64>> {
119        let start = Instant::now();
120        let mut result = SolveResult::new();
121        let mut placements = Vec::new();
122
123        let margin = self.config.margin;
124        let spacing = self.config.spacing;
125
126        let bound_max_x = boundary.width() - margin;
127        let bound_max_y = boundary.depth() - margin;
128        let bound_max_z = boundary.height() - margin;
129
130        // Simple layer-based placement
131        let mut current_x = margin;
132        let mut current_y = margin;
133        let mut current_z = margin;
134        let mut row_depth = 0.0_f64;
135        let mut layer_height = 0.0_f64;
136
137        let mut total_placed_volume = 0.0;
138        let mut total_placed_mass = 0.0;
139
140        for geom in geometries {
141            geom.validate()?;
142
143            for instance in 0..geom.quantity() {
144                if self.cancelled.load(Ordering::Relaxed) {
145                    result.computation_time_ms = start.elapsed().as_millis() as u64;
146                    return Ok(result);
147                }
148
149                // Check time limit (0 = unlimited)
150                if self.config.time_limit_ms > 0
151                    && start.elapsed().as_millis() as u64 >= self.config.time_limit_ms
152                {
153                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
154                    result.utilization = total_placed_volume / boundary.measure();
155                    result.computation_time_ms = start.elapsed().as_millis() as u64;
156                    result.placements = placements;
157                    return Ok(result);
158                }
159
160                // Check mass constraint
161                if let (Some(max_mass), Some(item_mass)) = (boundary.max_mass(), geom.mass()) {
162                    if total_placed_mass + item_mass > max_mass {
163                        result.unplaced.push(geom.id().clone());
164                        continue;
165                    }
166                }
167
168                // Try all allowed orientations to find the best fit
169                let orientations = geom.allowed_orientations();
170                let mut best_fit: BestFit3D = None;
171                // (orientation_idx, width, depth, height, place_x, place_y, place_z, new_row_depth, new_layer_height)
172
173                for (ori_idx, _) in orientations.iter().enumerate() {
174                    let dims = geom.dimensions_for_orientation(ori_idx);
175                    let g_width = dims.x;
176                    let g_depth = dims.y;
177                    let g_height = dims.z;
178
179                    // Try current position first
180                    let mut try_x = current_x;
181                    let mut try_y = current_y;
182                    let mut try_z = current_z;
183                    let mut try_row_depth = row_depth;
184                    let mut try_layer_height = layer_height;
185
186                    // Check if fits in current row
187                    if try_x + g_width > bound_max_x {
188                        try_x = margin;
189                        try_y += row_depth + spacing;
190                        try_row_depth = 0.0;
191                    }
192
193                    // Check if fits in current layer
194                    if try_y + g_depth > bound_max_y {
195                        try_x = margin;
196                        try_y = margin;
197                        try_z += layer_height + spacing;
198                        try_row_depth = 0.0;
199                        try_layer_height = 0.0;
200                    }
201
202                    // Check if fits in container
203                    if try_z + g_height > bound_max_z {
204                        continue; // This orientation doesn't fit
205                    }
206
207                    // Score: prefer placements that use less vertical space (height)
208                    // and stay in current row (lower y advancement)
209                    let score = try_z * 1000000.0 + try_y * 1000.0 + try_x + g_height * 0.1;
210
211                    let is_better = match &best_fit {
212                        None => true,
213                        Some((_, _, _, _, _, _, bz, _, _)) => {
214                            let best_score = bz * 1000000.0
215                                + best_fit.as_ref().unwrap().5 * 1000.0
216                                + best_fit.as_ref().unwrap().4
217                                + best_fit.as_ref().unwrap().3 * 0.1;
218                            score < best_score
219                        }
220                    };
221
222                    if is_better {
223                        best_fit = Some((
224                            ori_idx,
225                            g_width,
226                            g_depth,
227                            g_height,
228                            try_x,
229                            try_y,
230                            try_z,
231                            try_row_depth,
232                            try_layer_height,
233                        ));
234                    }
235                }
236
237                if let Some((
238                    ori_idx,
239                    g_width,
240                    g_depth,
241                    g_height,
242                    place_x,
243                    place_y,
244                    place_z,
245                    new_row_depth,
246                    new_layer_height,
247                )) = best_fit
248                {
249                    // Convert orientation index to rotation angles
250                    // For simplicity, we encode orientation in rotation_index
251                    let placement = Placement::new_3d(
252                        geom.id().clone(),
253                        instance,
254                        place_x,
255                        place_y,
256                        place_z,
257                        0.0, // Orientation is encoded via rotation_index
258                        0.0,
259                        0.0,
260                    )
261                    .with_rotation_index(ori_idx);
262
263                    placements.push(placement);
264                    total_placed_volume += geom.measure();
265                    if let Some(mass) = geom.mass() {
266                        total_placed_mass += mass;
267                    }
268
269                    // Update position for next item
270                    current_x = place_x + g_width + spacing;
271                    current_y = place_y;
272                    current_z = place_z;
273                    row_depth = new_row_depth.max(g_depth);
274                    layer_height = new_layer_height.max(g_height);
275                } else {
276                    result.unplaced.push(geom.id().clone());
277                }
278            }
279        }
280
281        result.placements = placements;
282        result.boundaries_used = 1;
283        result.utilization = total_placed_volume / boundary.measure();
284        result.computation_time_ms = start.elapsed().as_millis() as u64;
285
286        Ok(result)
287    }
288
289    /// Genetic Algorithm based packing optimization.
290    ///
291    /// Uses GA to optimize placement order and orientations, with layer-based
292    /// decoding for collision-free placements.
293    fn genetic_algorithm(
294        &self,
295        geometries: &[Geometry3D],
296        boundary: &Boundary3D,
297    ) -> Result<SolveResult<f64>> {
298        // Configure GA from solver config
299        let ga_config = GaConfig::default()
300            .with_population_size(self.config.population_size)
301            .with_max_generations(self.config.max_generations)
302            .with_crossover_rate(self.config.crossover_rate)
303            .with_mutation_rate(self.config.mutation_rate);
304
305        let result = run_ga_packing(
306            geometries,
307            boundary,
308            &self.config,
309            ga_config,
310            self.cancelled.clone(),
311        );
312
313        Ok(result)
314    }
315
316    /// BRKGA (Biased Random-Key Genetic Algorithm) based packing optimization.
317    ///
318    /// Uses random-key encoding and biased crossover for robust optimization.
319    fn brkga(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<SolveResult<f64>> {
320        // Configure BRKGA with reasonable defaults
321        let brkga_config = BrkgaConfig::default()
322            .with_population_size(50)
323            .with_max_generations(100)
324            .with_elite_fraction(0.2)
325            .with_mutant_fraction(0.15)
326            .with_elite_bias(0.7);
327
328        let result = run_brkga_packing(
329            geometries,
330            boundary,
331            &self.config,
332            brkga_config,
333            self.cancelled.clone(),
334        );
335
336        Ok(result)
337    }
338
339    /// Simulated Annealing based packing optimization.
340    ///
341    /// Uses neighborhood operators to explore solution space with temperature-based
342    /// acceptance probability.
343    fn simulated_annealing(
344        &self,
345        geometries: &[Geometry3D],
346        boundary: &Boundary3D,
347    ) -> Result<SolveResult<f64>> {
348        // Configure SA with reasonable defaults
349        let sa_config = SaConfig::default()
350            .with_initial_temp(100.0)
351            .with_final_temp(0.1)
352            .with_cooling_rate(0.95)
353            .with_iterations_per_temp(50)
354            .with_max_iterations(10000);
355
356        let result = run_sa_packing(
357            geometries,
358            boundary,
359            &self.config,
360            sa_config,
361            self.cancelled.clone(),
362        );
363
364        Ok(result)
365    }
366
367    /// Extreme Point heuristic-based packing.
368    ///
369    /// Places boxes at extreme points (positions touching at least two surfaces).
370    /// More efficient than layer-based packing for many scenarios.
371    fn extreme_point(
372        &self,
373        geometries: &[Geometry3D],
374        boundary: &Boundary3D,
375    ) -> Result<SolveResult<f64>> {
376        let start = Instant::now();
377
378        let (ep_placements, utilization) = run_ep_packing(
379            geometries,
380            boundary,
381            self.config.margin,
382            self.config.spacing,
383            boundary.max_mass(),
384        );
385
386        // Convert EP placements to Placement structs
387        let mut placements = Vec::new();
388        for (id, instance, position, _orientation) in ep_placements {
389            let placement = Placement::new_3d(
390                id, instance, position.x, position.y, position.z, 0.0, // rotation_x
391                0.0, // rotation_y
392                0.0, // rotation_z (orientation handled internally)
393            );
394            placements.push(placement);
395        }
396
397        // Collect unplaced items
398        let mut placed_ids: std::collections::HashSet<(String, usize)> =
399            std::collections::HashSet::new();
400        for p in &placements {
401            placed_ids.insert((p.geometry_id.clone(), p.instance));
402        }
403
404        let mut unplaced = Vec::new();
405        for geom in geometries {
406            for instance in 0..geom.quantity() {
407                if !placed_ids.contains(&(geom.id().clone(), instance)) {
408                    unplaced.push(geom.id().clone());
409                }
410            }
411        }
412
413        let mut result = SolveResult::new();
414        result.placements = placements;
415        result.boundaries_used = 1;
416        result.utilization = utilization;
417        result.unplaced = unplaced;
418        result.computation_time_ms = start.elapsed().as_millis() as u64;
419        result.strategy = Some("ExtremePoint".to_string());
420
421        Ok(result)
422    }
423
424    /// Layer packing with progress callback.
425    fn layer_packing_with_progress(
426        &self,
427        geometries: &[Geometry3D],
428        boundary: &Boundary3D,
429        callback: &ProgressCallback,
430    ) -> Result<SolveResult<f64>> {
431        let start = Instant::now();
432        let mut result = SolveResult::new();
433        let mut placements = Vec::new();
434
435        let margin = self.config.margin;
436        let spacing = self.config.spacing;
437
438        let bound_max_x = boundary.width() - margin;
439        let bound_max_y = boundary.depth() - margin;
440        let bound_max_z = boundary.height() - margin;
441
442        let mut current_x = margin;
443        let mut current_y = margin;
444        let mut current_z = margin;
445        let mut row_depth = 0.0_f64;
446        let mut layer_height = 0.0_f64;
447
448        let mut total_placed_volume = 0.0;
449        let mut total_placed_mass = 0.0;
450
451        // Count total pieces for progress
452        let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
453        let mut placed_count = 0usize;
454
455        // Initial progress callback
456        callback(
457            ProgressInfo::new()
458                .with_phase("Layer Packing")
459                .with_items(0, total_pieces)
460                .with_elapsed(0),
461        );
462
463        for geom in geometries {
464            geom.validate()?;
465
466            for instance in 0..geom.quantity() {
467                if self.cancelled.load(Ordering::Relaxed) {
468                    result.computation_time_ms = start.elapsed().as_millis() as u64;
469                    callback(
470                        ProgressInfo::new()
471                            .with_phase("Cancelled")
472                            .with_items(placed_count, total_pieces)
473                            .with_elapsed(result.computation_time_ms)
474                            .finished(),
475                    );
476                    return Ok(result);
477                }
478
479                // Check time limit (0 = unlimited)
480                if self.config.time_limit_ms > 0
481                    && start.elapsed().as_millis() as u64 >= self.config.time_limit_ms
482                {
483                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
484                    result.utilization = total_placed_volume / boundary.measure();
485                    result.computation_time_ms = start.elapsed().as_millis() as u64;
486                    result.placements = placements;
487                    callback(
488                        ProgressInfo::new()
489                            .with_phase("Time Limit Reached")
490                            .with_items(placed_count, total_pieces)
491                            .with_elapsed(result.computation_time_ms)
492                            .finished(),
493                    );
494                    return Ok(result);
495                }
496
497                // Check mass constraint
498                if let (Some(max_mass), Some(item_mass)) = (boundary.max_mass(), geom.mass()) {
499                    if total_placed_mass + item_mass > max_mass {
500                        result.unplaced.push(geom.id().clone());
501                        continue;
502                    }
503                }
504
505                // Try all allowed orientations to find the best fit
506                let orientations = geom.allowed_orientations();
507                let mut best_fit: BestFit3D = None;
508
509                for (ori_idx, _) in orientations.iter().enumerate() {
510                    let dims = geom.dimensions_for_orientation(ori_idx);
511                    let g_width = dims.x;
512                    let g_depth = dims.y;
513                    let g_height = dims.z;
514
515                    let mut try_x = current_x;
516                    let mut try_y = current_y;
517                    let mut try_z = current_z;
518                    let mut try_row_depth = row_depth;
519                    let mut try_layer_height = layer_height;
520
521                    if try_x + g_width > bound_max_x {
522                        try_x = margin;
523                        try_y += row_depth + spacing;
524                        try_row_depth = 0.0;
525                    }
526
527                    if try_y + g_depth > bound_max_y {
528                        try_x = margin;
529                        try_y = margin;
530                        try_z += layer_height + spacing;
531                        try_row_depth = 0.0;
532                        try_layer_height = 0.0;
533                    }
534
535                    if try_z + g_height > bound_max_z {
536                        continue;
537                    }
538
539                    let score = try_z * 1000000.0 + try_y * 1000.0 + try_x + g_height * 0.1;
540
541                    let is_better = match &best_fit {
542                        None => true,
543                        Some((_, _, _, _, _, _, bz, _, _)) => {
544                            let best_score = bz * 1000000.0
545                                + best_fit.as_ref().unwrap().5 * 1000.0
546                                + best_fit.as_ref().unwrap().4
547                                + best_fit.as_ref().unwrap().3 * 0.1;
548                            score < best_score
549                        }
550                    };
551
552                    if is_better {
553                        best_fit = Some((
554                            ori_idx,
555                            g_width,
556                            g_depth,
557                            g_height,
558                            try_x,
559                            try_y,
560                            try_z,
561                            try_row_depth,
562                            try_layer_height,
563                        ));
564                    }
565                }
566
567                if let Some((
568                    ori_idx,
569                    g_width,
570                    g_depth,
571                    g_height,
572                    place_x,
573                    place_y,
574                    place_z,
575                    new_row_depth,
576                    new_layer_height,
577                )) = best_fit
578                {
579                    let placement = Placement::new_3d(
580                        geom.id().clone(),
581                        instance,
582                        place_x,
583                        place_y,
584                        place_z,
585                        0.0,
586                        0.0,
587                        0.0,
588                    )
589                    .with_rotation_index(ori_idx);
590
591                    placements.push(placement);
592                    total_placed_volume += geom.measure();
593                    if let Some(mass) = geom.mass() {
594                        total_placed_mass += mass;
595                    }
596                    placed_count += 1;
597
598                    current_x = place_x + g_width + spacing;
599                    current_y = place_y;
600                    current_z = place_z;
601                    row_depth = new_row_depth.max(g_depth);
602                    layer_height = new_layer_height.max(g_height);
603
604                    // Progress callback every piece
605                    callback(
606                        ProgressInfo::new()
607                            .with_phase("Layer Packing")
608                            .with_items(placed_count, total_pieces)
609                            .with_utilization(total_placed_volume / boundary.measure())
610                            .with_elapsed(start.elapsed().as_millis() as u64),
611                    );
612                } else {
613                    result.unplaced.push(geom.id().clone());
614                }
615            }
616        }
617
618        result.placements = placements;
619        result.boundaries_used = 1;
620        result.utilization = total_placed_volume / boundary.measure();
621        result.computation_time_ms = start.elapsed().as_millis() as u64;
622
623        // Final progress callback
624        callback(
625            ProgressInfo::new()
626                .with_phase("Complete")
627                .with_items(placed_count, total_pieces)
628                .with_utilization(result.utilization)
629                .with_elapsed(result.computation_time_ms)
630                .finished(),
631        );
632
633        Ok(result)
634    }
635}
636
637impl Solver for Packer3D {
638    type Geometry = Geometry3D;
639    type Boundary = Boundary3D;
640    type Scalar = f64;
641
642    fn solve(
643        &self,
644        geometries: &[Self::Geometry],
645        boundary: &Self::Boundary,
646    ) -> Result<SolveResult<f64>> {
647        boundary.validate()?;
648
649        // Reset cancellation flag
650        self.cancelled.store(false, Ordering::Relaxed);
651
652        let mut result = match self.config.strategy {
653            Strategy::BottomLeftFill => self.layer_packing(geometries, boundary),
654            Strategy::ExtremePoint => self.extreme_point(geometries, boundary),
655            Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
656            Strategy::Brkga => self.brkga(geometries, boundary),
657            Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
658            _ => {
659                // Fall back to layer packing for unimplemented strategies
660                log::warn!(
661                    "Strategy {:?} not yet implemented, using layer packing",
662                    self.config.strategy
663                );
664                self.layer_packing(geometries, boundary)
665            }
666        }?;
667
668        // Remove duplicate entries from unplaced list
669        result.deduplicate_unplaced();
670        Ok(result)
671    }
672
673    fn solve_with_progress(
674        &self,
675        geometries: &[Self::Geometry],
676        boundary: &Self::Boundary,
677        callback: ProgressCallback,
678    ) -> Result<SolveResult<f64>> {
679        boundary.validate()?;
680
681        // Reset cancellation flag
682        self.cancelled.store(false, Ordering::Relaxed);
683
684        let mut result = match self.config.strategy {
685            Strategy::BottomLeftFill | Strategy::ExtremePoint => {
686                self.layer_packing_with_progress(geometries, boundary, &callback)?
687            }
688            // Other strategies fall back to basic progress reporting
689            _ => {
690                log::warn!(
691                    "Strategy {:?} progress not yet implemented, using layer packing",
692                    self.config.strategy
693                );
694                self.layer_packing_with_progress(geometries, boundary, &callback)?
695            }
696        };
697
698        // Remove duplicate entries from unplaced list
699        result.deduplicate_unplaced();
700        Ok(result)
701    }
702
703    fn cancel(&self) {
704        self.cancelled.store(true, Ordering::Relaxed);
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn test_simple_packing() {
714        let geometries = vec![
715            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
716            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
717        ];
718
719        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
720        let packer = Packer3D::default_config();
721
722        let result = packer.solve(&geometries, &boundary).unwrap();
723
724        assert!(result.utilization > 0.0);
725        assert!(result.placements.len() <= 5);
726    }
727
728    #[test]
729    fn test_mass_constraint() {
730        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
731            .with_quantity(10)
732            .with_mass(100.0)];
733
734        let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
735
736        let packer = Packer3D::default_config();
737        let result = packer.solve(&geometries, &boundary).unwrap();
738
739        // Should only place 3 boxes (300 mass) due to 350 mass limit
740        assert!(result.placements.len() <= 3);
741    }
742
743    #[test]
744    fn test_placement_within_bounds() {
745        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
746
747        let boundary = Boundary3D::new(50.0, 50.0, 50.0);
748        let config = Config::default().with_margin(5.0).with_spacing(2.0);
749        let packer = Packer3D::new(config);
750
751        let result = packer.solve(&geometries, &boundary).unwrap();
752
753        // All boxes should be placed
754        assert_eq!(result.placements.len(), 4);
755        assert!(result.unplaced.is_empty());
756
757        // Verify placements are within bounds (with margin)
758        for p in &result.placements {
759            assert!(p.position[0] >= 5.0);
760            assert!(p.position[1] >= 5.0);
761            assert!(p.position[2] >= 5.0);
762        }
763    }
764
765    #[test]
766    fn test_ga_strategy_basic() {
767        let geometries = vec![
768            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
769            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
770        ];
771
772        let boundary = Boundary3D::new(100.0, 80.0, 50.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        // GA should place items and achieve positive utilization
779        assert!(result.utilization > 0.0);
780        assert!(!result.placements.is_empty());
781    }
782
783    #[test]
784    fn test_ga_strategy_all_placed() {
785        // Small number of boxes that should all fit
786        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
787
788        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
789        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
790        let packer = Packer3D::new(config);
791
792        let result = packer.solve(&geometries, &boundary).unwrap();
793
794        // All 4 boxes should be placed
795        assert_eq!(result.placements.len(), 4);
796        assert!(result.unplaced.is_empty());
797    }
798
799    #[test]
800    fn test_ga_strategy_with_orientations() {
801        use crate::geometry::OrientationConstraint;
802
803        // Box that fits better when rotated
804        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
805            .with_quantity(2)
806            .with_orientation(OrientationConstraint::Any)];
807
808        // Container where orientation matters
809        let boundary = Boundary3D::new(60.0, 60.0, 60.0);
810        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
811        let packer = Packer3D::new(config);
812
813        let result = packer.solve(&geometries, &boundary).unwrap();
814
815        // GA should find a way to place both boxes
816        assert_eq!(result.placements.len(), 2);
817    }
818
819    #[test]
820    fn test_brkga_strategy_basic() {
821        let geometries = vec![
822            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
823            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
824        ];
825
826        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
827        let config = Config::default().with_strategy(Strategy::Brkga);
828        let packer = Packer3D::new(config);
829
830        let result = packer.solve(&geometries, &boundary).unwrap();
831
832        // BRKGA should place items and achieve positive utilization
833        assert!(result.utilization > 0.0);
834        assert!(!result.placements.is_empty());
835        assert_eq!(result.strategy, Some("BRKGA".to_string()));
836    }
837
838    #[test]
839    fn test_brkga_strategy_all_placed() {
840        // Small number of boxes that should all fit
841        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
842
843        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
844        let config = Config::default().with_strategy(Strategy::Brkga);
845        let packer = Packer3D::new(config);
846
847        let result = packer.solve(&geometries, &boundary).unwrap();
848
849        // All 4 boxes should be placed
850        assert_eq!(result.placements.len(), 4);
851        assert!(result.unplaced.is_empty());
852    }
853
854    #[test]
855    fn test_ep_strategy_basic() {
856        let geometries = vec![
857            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
858            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
859        ];
860
861        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
862        let config = Config::default().with_strategy(Strategy::ExtremePoint);
863        let packer = Packer3D::new(config);
864
865        let result = packer.solve(&geometries, &boundary).unwrap();
866
867        // EP should place items and achieve positive utilization
868        assert!(result.utilization > 0.0);
869        assert!(!result.placements.is_empty());
870        assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
871    }
872
873    #[test]
874    fn test_ep_strategy_all_placed() {
875        // Small number of boxes that should all fit
876        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
877
878        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
879        let config = Config::default().with_strategy(Strategy::ExtremePoint);
880        let packer = Packer3D::new(config);
881
882        let result = packer.solve(&geometries, &boundary).unwrap();
883
884        // All 4 boxes should be placed
885        assert_eq!(result.placements.len(), 4);
886        assert!(result.unplaced.is_empty());
887    }
888
889    #[test]
890    fn test_ep_strategy_with_margin() {
891        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
892
893        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
894        let config = Config::default()
895            .with_strategy(Strategy::ExtremePoint)
896            .with_margin(5.0);
897        let packer = Packer3D::new(config);
898
899        let result = packer.solve(&geometries, &boundary).unwrap();
900
901        // Verify placements start at margin
902        for p in &result.placements {
903            assert!(p.position[0] >= 4.9);
904            assert!(p.position[1] >= 4.9);
905            assert!(p.position[2] >= 4.9);
906        }
907    }
908
909    #[test]
910    fn test_ep_strategy_with_orientations() {
911        use crate::geometry::OrientationConstraint;
912
913        // Long box that benefits from rotation
914        let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
915            .with_quantity(2)
916            .with_orientation(OrientationConstraint::Any)];
917
918        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
919        let config = Config::default().with_strategy(Strategy::ExtremePoint);
920        let packer = Packer3D::new(config);
921
922        let result = packer.solve(&geometries, &boundary).unwrap();
923
924        // EP should find a way to place both boxes
925        assert_eq!(result.placements.len(), 2);
926    }
927
928    #[test]
929    fn test_layer_packing_orientation_optimization() {
930        use crate::geometry::OrientationConstraint;
931
932        // A box 50x10x10 that won't fit in 45 width without rotation
933        // But at orientation (1,0,2) it becomes 10x50x10, width=10, which fits
934        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
935            .with_quantity(2)
936            .with_orientation(OrientationConstraint::Any)];
937
938        // Narrow container: width=45, depth=80, height=80
939        let boundary = Boundary3D::new(45.0, 80.0, 80.0);
940        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
941        let packer = Packer3D::new(config);
942
943        let result = packer.solve(&geometries, &boundary).unwrap();
944
945        // Both boxes should be placed via orientation change
946        assert_eq!(
947            result.placements.len(),
948            2,
949            "Both boxes should be placed by using rotation"
950        );
951        assert!(result.unplaced.is_empty());
952
953        // Verify orientation index is set for placements
954        for p in &result.placements {
955            assert!(
956                p.rotation_index.is_some(),
957                "Placement should have rotation_index set"
958            );
959        }
960    }
961
962    #[test]
963    fn test_layer_packing_selects_best_orientation() {
964        use crate::geometry::OrientationConstraint;
965
966        // Box 30x20x10 in container 35x50x100
967        // Original orientation (30x20x10): fits in row, leaves 5 spare width
968        // Rotated (20x30x10): fits but uses more depth
969        // Best: original orientation to minimize vertical space usage
970        let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
971            .with_quantity(1)
972            .with_orientation(OrientationConstraint::Any)];
973
974        let boundary = Boundary3D::new(35.0, 50.0, 100.0);
975        let packer = Packer3D::default_config();
976
977        let result = packer.solve(&geometries, &boundary).unwrap();
978
979        assert_eq!(result.placements.len(), 1);
980        assert!(result.unplaced.is_empty());
981    }
982}