Skip to main content

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::packing_utils::{build_unplaced_list, layer_fill_in_order};
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
24/// The GA population when the caller leaves [`Config::population_size`]
25/// unset. A 3D individual is decoded by layer placement, cheap next to a 2D
26/// no-fit-polygon layout, so the run is larger than the 2D one.
27const GA_POPULATION: usize = 100;
28
29/// The GA generation budget when the caller leaves
30/// [`Config::max_generations`] unset.
31const GA_GENERATIONS: u32 = 500;
32
33/// The BRKGA population when the caller leaves [`Config::population_size`]
34/// unset.
35const BRKGA_POPULATION: usize = 50;
36
37/// The BRKGA generation budget when the caller leaves
38/// [`Config::max_generations`] unset.
39const BRKGA_GENERATIONS: u32 = 100;
40
41/// 3D bin packing solver.
42pub struct Packer3D {
43    config: Config,
44    cancelled: Arc<AtomicBool>,
45}
46
47impl Packer3D {
48    /// Runs the configured strategy. A strategy 3D packing does not provide is
49    /// refused rather than replaced by layer packing.
50    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    /// Creates a new packer with the given configuration.
72    pub fn new(config: Config) -> Self {
73        Self {
74            config,
75            cancelled: Arc::new(AtomicBool::new(false)),
76        }
77    }
78
79    /// Creates a packer with default configuration.
80    pub fn default_config() -> Self {
81        Self::new(Config::default())
82    }
83
84    /// The GA run the configuration asks for: the caller's population and
85    /// generation budget, [`GA_POPULATION`] / [`GA_GENERATIONS`] for the ones
86    /// left unset.
87    fn ga_config(&self) -> GaConfig {
88        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    }
95
96    /// The BRKGA run the configuration asks for: the caller's population and
97    /// generation budget, [`BRKGA_POPULATION`] / [`BRKGA_GENERATIONS`] for the
98    /// ones left unset.
99    fn brkga_config(&self) -> BrkgaConfig {
100        BrkgaConfig::default()
101            .with_population_size(self.config.population_size.unwrap_or(BRKGA_POPULATION))
102            .with_max_generations(self.config.max_generations.unwrap_or(BRKGA_GENERATIONS))
103            .with_elite_fraction(0.2)
104            .with_mutant_fraction(0.15)
105            .with_elite_bias(0.7)
106    }
107
108    /// Judges the request once, at the solve entry point, whichever strategy
109    /// runs: the configuration, the boundary, the geometries -- an empty list
110    /// is `empty_input`, there is nothing to pack -- and their ids.
111    fn validate_inputs(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<()> {
112        boundary.validate()?;
113        if geometries.is_empty() {
114            return Err(Error::empty_input("geometries"));
115        }
116        self.config.validate()?;
117        for geom in geometries {
118            geom.validate()?;
119        }
120        u_nesting_core::geometry::ensure_unique_ids(geometries)
121    }
122
123    /// Validates the stability of a packing result.
124    ///
125    /// Analyzes each placement to ensure boxes are properly supported.
126    ///
127    /// # Errors
128    ///
129    /// The refusal of [`StabilityConstraint::validate`].
130    pub fn validate_stability(
131        &self,
132        result: &SolveResult<f64>,
133        geometries: &[Geometry3D],
134        _boundary: &Boundary3D,
135        constraint: StabilityConstraint,
136    ) -> Result<StabilityReport> {
137        // Convert placements to PlacedBox format
138        let placed_boxes = self.placements_to_boxes(result, geometries);
139        let analyzer = StabilityAnalyzer::new(constraint)?;
140        Ok(analyzer.analyze(&placed_boxes, 0.0))
141    }
142
143    /// Validates stability using physics simulation.
144    ///
145    /// Runs a physics simulation to detect boxes that would fall or tip.
146    pub fn validate_stability_physics(
147        &self,
148        result: &SolveResult<f64>,
149        geometries: &[Geometry3D],
150        boundary: &Boundary3D,
151    ) -> StabilityReport {
152        let placed_boxes = self.placements_to_boxes(result, geometries);
153        let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
154
155        let config = PhysicsConfig::default().with_max_time(2.0);
156        let simulator =
157            PhysicsSimulator::new(config).expect("the default physics settings are in range");
158        simulator.validate_stability(&placed_boxes, container, 0.0)
159    }
160
161    /// Enforces the boundary's gravity/stability constraints on a finished packing.
162    ///
163    /// Strategies optimise for volume, not support, so a result may contain boxes that
164    /// float or rest on too little base. When the boundary requests gravity and/or
165    /// stability, this removes the offending boxes (moving them to `unplaced`) so the
166    /// returned packing actually satisfies the constraint the caller asked for — rather
167    /// than the flags being silently ignored.
168    ///
169    /// Removal iterates: dropping a box can leave the boxes it supported unsupported, so
170    /// the result is re-validated until every remaining box is stable.
171    fn enforce_support(
172        &self,
173        result: &mut SolveResult<f64>,
174        geometries: &[Geometry3D],
175        boundary: &Boundary3D,
176    ) {
177        // Stability is the stronger requirement (a real base-support ratio); plain
178        // gravity only forbids floating, i.e. demands any positive contact below.
179        let constraint = if boundary.has_stability() {
180            StabilityConstraint::partial_base(0.7)
181        } else {
182            StabilityConstraint::partial_base(1e-6)
183        };
184
185        // The pack rests boxes on z = margin (the inset floor), so the support analysis
186        // must use that as the floor — checking against z = 0 would read every bottom
187        // box as floating and drop the whole pack when margin > 0.
188        let floor_z = self.config.margin;
189        let analyzer = StabilityAnalyzer::new(constraint)
190            .expect("the support ratios used here, 0.7 and 1e-6, are in range");
191        while !result.placements.is_empty() {
192            let placed_boxes = self.placements_to_boxes(result, geometries);
193            let report = analyzer.analyze(&placed_boxes, floor_z);
194            if report.is_all_stable() {
195                break;
196            }
197            let drop: std::collections::HashSet<(String, usize)> = report
198                .unstable_boxes()
199                .iter()
200                .map(|r| (r.id.clone(), r.instance))
201                .collect();
202            result
203                .placements
204                .retain(|p| !drop.contains(&(p.geometry_id.clone(), p.instance)));
205            for (id, _) in drop {
206                result.unplaced.push(id);
207            }
208        }
209
210        result.deduplicate_unplaced();
211        // Placed volume shrank, so the reported utilization must follow.
212        let boxes = self.placements_to_boxes(result, geometries);
213        let placed_volume: f64 = boxes
214            .iter()
215            .map(|b| b.dimensions.x * b.dimensions.y * b.dimensions.z)
216            .sum();
217        let container_volume = boundary.measure();
218        result.utilization = if container_volume > 0.0 {
219            placed_volume / container_volume
220        } else {
221            0.0
222        };
223    }
224
225    /// Converts placements to PlacedBox format for stability analysis.
226    fn placements_to_boxes(
227        &self,
228        result: &SolveResult<f64>,
229        geometries: &[Geometry3D],
230    ) -> Vec<PlacedBox> {
231        let geom_map: std::collections::HashMap<&str, &Geometry3D> =
232            geometries.iter().map(|g| (g.id().as_str(), g)).collect();
233
234        result
235            .placements
236            .iter()
237            .filter_map(|p| {
238                let geom = geom_map.get(p.geometry_id.as_str())?;
239                let ori_idx = p.rotation_index.unwrap_or(0);
240                let dims = geom.dimensions_for_orientation(ori_idx);
241
242                let mut placed = PlacedBox::new(
243                    p.geometry_id.clone(),
244                    p.instance,
245                    Point3::new(p.position[0], p.position[1], p.position[2]),
246                    dims,
247                );
248
249                if let Some(mass) = geom.mass() {
250                    placed = placed.with_mass(mass);
251                }
252
253                Some(placed)
254            })
255            .collect()
256    }
257
258    /// Layer packing: every box in request order, each in its best orientation
259    /// (see [`layer_fill_in_order`]), within the time limit.
260    fn layer_packing(
261        &self,
262        geometries: &[Geometry3D],
263        boundary: &Boundary3D,
264    ) -> Result<SolveResult<f64>> {
265        Ok(self.layer_fill(geometries, boundary, None))
266    }
267
268    /// The layer fill, reporting progress to `callback` when there is one:
269    /// once before, once per box placed and once at the end.
270    fn layer_fill(
271        &self,
272        geometries: &[Geometry3D],
273        boundary: &Boundary3D,
274        callback: Option<&ProgressCallback>,
275    ) -> SolveResult<f64> {
276        let start = Timer::now();
277        let total: usize = geometries.iter().map(|g| g.quantity()).sum();
278        let report = |info: ProgressInfo| {
279            if let Some(callback) = callback {
280                callback(info);
281            }
282        };
283        report(
284            ProgressInfo::new()
285                .with_phase("Layer Packing")
286                .with_items(0, total)
287                .with_elapsed(0),
288        );
289
290        let mut cancelled = false;
291        let mut timed_out = false;
292        let (fill, _stopped) = layer_fill_in_order(
293            geometries,
294            boundary,
295            &self.config,
296            || {
297                cancelled = self.cancelled.load(Ordering::Relaxed);
298                timed_out = self.config.time_limit_ms > 0
299                    && start.elapsed_ms() >= self.config.time_limit_ms;
300                cancelled || timed_out
301            },
302            |fill| {
303                report(
304                    ProgressInfo::new()
305                        .with_phase("Layer Packing")
306                        .with_items(fill.placed(), total)
307                        .with_utilization(fill.utilization())
308                        .with_elapsed(start.elapsed_ms()),
309                )
310            },
311        );
312
313        let mut result = SolveResult::new();
314        result.unplaced = build_unplaced_list(&fill.placements, geometries);
315        result.boundaries_used = usize::from(!fill.placements.is_empty());
316        result.utilization = fill.utilization;
317        result.placements = fill.placements;
318        result.cancelled = cancelled;
319        result.computation_time_ms = start.elapsed_ms();
320        let phase = if cancelled {
321            "Cancelled"
322        } else if timed_out {
323            "Time Limit Reached"
324        } else {
325            "Complete"
326        };
327        report(
328            ProgressInfo::new()
329                .with_phase(phase)
330                .with_items(result.placements.len(), total)
331                .with_utilization(result.utilization)
332                .with_elapsed(result.computation_time_ms)
333                .finished(),
334        );
335        result
336    }
337
338    /// The time a search may run after the layer fill it starts from:
339    /// what is left of the limit, `None` when there is no limit.
340    fn search_time_limit(&self, started: &Timer) -> Option<std::time::Duration> {
341        (self.config.time_limit_ms > 0).then(|| {
342            std::time::Duration::from_millis(
343                self.config
344                    .time_limit_ms
345                    .saturating_sub(started.elapsed_ms())
346                    .max(1),
347            )
348        })
349    }
350
351    /// Returns the search's packing unless a simple fill -- the layer fill it
352    /// started from, or the extreme-point fill -- packs better: more boxes,
353    /// then a higher utilization. Packings are compared as the caller gets
354    /// them, after gravity and stability have dropped the boxes they do not
355    /// allow, since a search scores its packings before that.
356    ///
357    /// A search can stop, on a small population, generation or time budget,
358    /// at a packing worse than the one pass of a simple fill; flooring it here
359    /// means a search never returns less. A tie keeps the search's packing.
360    fn not_worse_than_fills(
361        &self,
362        search: SolveResult<f64>,
363        geometries: &[Geometry3D],
364        boundary: &Boundary3D,
365        layer_fill: SolveResult<f64>,
366    ) -> SolveResult<f64> {
367        let supported = |mut r: SolveResult<f64>| {
368            if boundary.has_gravity() || boundary.has_stability() {
369                self.enforce_support(&mut r, geometries, boundary);
370            }
371            r
372        };
373        let fills = [
374            Some(layer_fill),
375            self.extreme_point(geometries, boundary).ok(),
376        ]
377        .into_iter()
378        .flatten()
379        .map(supported);
380        supported(search).floored_at(fills, |a, b| {
381            let (pa, pb) = (a.placements.len(), b.placements.len());
382            pa > pb || (pa == pb && a.utilization > b.utilization + 1e-9)
383        })
384    }
385
386    /// Genetic Algorithm based packing optimization.
387    ///
388    /// Optimizes placement order and orientations with layer-based decoding,
389    /// starting from the layer fill and floored at the simple fills.
390    fn genetic_algorithm(
391        &self,
392        geometries: &[Geometry3D],
393        boundary: &Boundary3D,
394    ) -> Result<SolveResult<f64>> {
395        let started = Timer::now();
396        let fill = self.layer_fill(geometries, boundary, None);
397        let mut ga_config = self.ga_config();
398        ga_config.time_limit = self.search_time_limit(&started);
399        let result = run_ga_packing(
400            geometries,
401            boundary,
402            &self.config,
403            ga_config,
404            self.cancelled.clone(),
405            Some(&fill.placements),
406        )?;
407        Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
408    }
409
410    /// BRKGA (Biased Random-Key Genetic Algorithm) based packing optimization.
411    ///
412    /// Random-key encoding and biased crossover, starting from the layer fill
413    /// and floored at the simple fills.
414    fn brkga(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<SolveResult<f64>> {
415        let started = Timer::now();
416        let fill = self.layer_fill(geometries, boundary, None);
417        let mut brkga_config = self.brkga_config();
418        brkga_config.time_limit = self.search_time_limit(&started);
419        let result = run_brkga_packing(
420            geometries,
421            boundary,
422            &self.config,
423            brkga_config,
424            self.cancelled.clone(),
425            Some(&fill.placements),
426        )?;
427        Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
428    }
429
430    /// Simulated Annealing based packing optimization.
431    ///
432    /// Neighborhood moves with temperature-based acceptance, starting from
433    /// the layer fill and floored at the simple fills.
434    fn simulated_annealing(
435        &self,
436        geometries: &[Geometry3D],
437        boundary: &Boundary3D,
438    ) -> Result<SolveResult<f64>> {
439        let started = Timer::now();
440        let fill = self.layer_fill(geometries, boundary, None);
441        let mut sa_config = SaConfig::default()
442            .with_initial_temp(100.0)
443            .with_final_temp(0.1)
444            .with_cooling_rate(0.95)
445            .with_iterations_per_temp(50)
446            .with_max_iterations(10000);
447        sa_config.time_limit = self.search_time_limit(&started);
448
449        let result = run_sa_packing(
450            geometries,
451            boundary,
452            &self.config,
453            sa_config,
454            self.cancelled.clone(),
455            Some(&fill.placements),
456        )?;
457        Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
458    }
459
460    /// Extreme Point heuristic-based packing.
461    ///
462    /// Places boxes at extreme points (positions touching at least two surfaces).
463    /// More efficient than layer-based packing for many scenarios.
464    fn extreme_point(
465        &self,
466        geometries: &[Geometry3D],
467        boundary: &Boundary3D,
468    ) -> Result<SolveResult<f64>> {
469        let start = Timer::now();
470
471        let (ep_placements, utilization) = run_ep_packing(
472            geometries,
473            boundary,
474            self.config.margin,
475            self.config.spacing,
476            boundary.max_mass(),
477        );
478
479        // Convert EP placements to Placement structs. The orientation index must be
480        // preserved via `rotation_index` so the response can report the actual box
481        // footprint; dropping it made every EP placement report the identity
482        // orientation, which read as out-of-bounds for rotated boxes downstream.
483        let mut placements = Vec::new();
484        for (id, instance, position, orientation) in ep_placements {
485            let placement = Placement::new_3d(
486                id, instance, position.x, position.y, position.z, 0.0, // rotation_x
487                0.0, // rotation_y
488                0.0, // rotation_z (orientation encoded via rotation_index)
489            )
490            .with_rotation_index(orientation);
491            placements.push(placement);
492        }
493
494        let unplaced = build_unplaced_list(&placements, geometries);
495
496        let mut result = SolveResult::new();
497        result.placements = placements;
498        result.boundaries_used = 1;
499        result.utilization = utilization;
500        result.unplaced = unplaced;
501        result.computation_time_ms = start.elapsed_ms();
502        result.strategy = Some("ExtremePoint".to_string());
503
504        Ok(result)
505    }
506}
507
508impl Solver for Packer3D {
509    type Geometry = Geometry3D;
510    type Boundary = Boundary3D;
511    type Scalar = f64;
512
513    fn solve(
514        &self,
515        geometries: &[Self::Geometry],
516        boundary: &Self::Boundary,
517    ) -> Result<SolveResult<f64>> {
518        self.validate_inputs(geometries, boundary)?;
519
520        // Reset cancellation flag
521        self.cancelled.store(false, Ordering::Relaxed);
522
523        let mut result = self.run_strategy(geometries, boundary)?;
524
525        // Remove duplicate entries from unplaced list
526        result.deduplicate_unplaced();
527        // Authoritative instance-level request total (Σ quantity), recorded once
528        // at the top-level entry point where `geometries` is the full request.
529        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
530
531        // Honor gravity/stability constraints, if requested, on the finished packing.
532        if boundary.has_gravity() || boundary.has_stability() {
533            self.enforce_support(&mut result, geometries, boundary);
534        }
535        Ok(result)
536    }
537
538    fn solve_with_progress(
539        &self,
540        geometries: &[Self::Geometry],
541        boundary: &Self::Boundary,
542        callback: ProgressCallback,
543    ) -> Result<SolveResult<f64>> {
544        self.validate_inputs(geometries, boundary)?;
545
546        // Reset cancellation flag
547        self.cancelled.store(false, Ordering::Relaxed);
548
549        let mut result = match self.config.strategy {
550            Strategy::BottomLeftFill => self.layer_fill(geometries, boundary, Some(&callback)),
551            // EP is a fast single pass; run the real heuristic (not layer packing) and
552            // skip incremental progress rather than silently substituting BLF.
553            Strategy::ExtremePoint => self.extreme_point(geometries, boundary)?,
554            // The other strategies have no progress reporting yet: run the
555            // strategy that was asked for, without progress, rather than a
556            // different one that reports it.
557            _ => self.run_strategy(geometries, boundary)?,
558        };
559
560        // Remove duplicate entries from unplaced list
561        result.deduplicate_unplaced();
562        // Authoritative instance-level request total (Σ quantity), recorded once
563        // at the top-level entry point where `geometries` is the full request.
564        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
565
566        // Honor gravity/stability constraints, if requested, on the finished packing.
567        if boundary.has_gravity() || boundary.has_stability() {
568            self.enforce_support(&mut result, geometries, boundary);
569        }
570        Ok(result)
571    }
572
573    fn cancel(&self) {
574        self.cancelled.store(true, Ordering::Relaxed);
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
584        let geometries = [
585            Geometry3D::new("B", 10.0, 10.0, 10.0),
586            Geometry3D::new("B", 50.0, 50.0, 50.0),
587        ];
588        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
589        let packer = Packer3D::default_config();
590        let errors = [
591            packer.solve(&geometries, &boundary).expect_err("solve"),
592            packer
593                .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
594                .expect_err("solve_with_progress"),
595        ];
596        for err in errors {
597            let msg = err.to_string();
598            assert!(msg.contains("'B'"), "{msg}");
599            assert!(msg.contains("positions 0 and 1"), "{msg}");
600        }
601    }
602
603    /// Nothing to pack is refused, and every entry point judges the whole
604    /// request the same way -- the progress entry point used to skip the
605    /// configuration, and no entry point judged the boxes before the strategy
606    /// ran.
607    #[test]
608    fn every_entry_point_judges_the_whole_request() {
609        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
610        let packer = Packer3D::default_config();
611        let empty = Error::EmptyInput {
612            parameter: "geometries".into(),
613        };
614        assert_eq!(packer.solve(&[], &boundary).expect_err("solve"), empty);
615        assert_eq!(
616            packer
617                .solve_with_progress(&[], &boundary, Box::new(|_| {}))
618                .expect_err("solve_with_progress"),
619            empty
620        );
621
622        let boxes = [Geometry3D::new("B", 10.0, 10.0, 10.0)];
623        let bad_config = Packer3D::new(Config::default().with_spacing(-1.0));
624        for err in [
625            bad_config.solve(&boxes, &boundary).expect_err("solve"),
626            bad_config
627                .solve_with_progress(&boxes, &boundary, Box::new(|_| {}))
628                .expect_err("solve_with_progress"),
629        ] {
630            assert_eq!(err.code(), "parameter_out_of_range", "{err}");
631        }
632
633        let flat = [Geometry3D::new("flat", 10.0, 0.0, 10.0)];
634        for strategy in [Strategy::ExtremePoint, Strategy::GeneticAlgorithm] {
635            let packer = Packer3D::new(Config::default().with_strategy(strategy));
636            match packer.solve(&flat, &boundary) {
637                Err(Error::OutOfRange { parameter, id, .. }) => {
638                    assert_eq!(parameter, "dimensions[1]");
639                    assert_eq!(id.as_deref(), Some("flat"));
640                }
641                other => panic!("{strategy:?}: {other:?}"),
642            }
643        }
644    }
645
646    /// A population or generation budget the caller sets is the one the
647    /// search runs with -- BRKGA used to run 50 x 100 whatever was asked;
648    /// left unset, each search runs with its own.
649    #[test]
650    fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
651        let unset = Packer3D::default_config();
652        assert_eq!(unset.ga_config().population_size, 100);
653        assert_eq!(unset.ga_config().max_generations, 500);
654        assert_eq!(unset.brkga_config().population_size, 50);
655        assert_eq!(unset.brkga_config().max_generations, 100);
656
657        let set = Packer3D::new(
658            Config::default()
659                .with_population_size(12)
660                .with_max_generations(3),
661        );
662        assert_eq!(set.ga_config().population_size, 12);
663        assert_eq!(set.ga_config().max_generations, 3);
664        assert_eq!(set.brkga_config().population_size, 12);
665        assert_eq!(set.brkga_config().max_generations, 3);
666    }
667
668    #[test]
669    fn test_simple_packing() {
670        let geometries = vec![
671            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
672            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
673        ];
674
675        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
676        let packer = Packer3D::default_config();
677
678        let result = packer.solve(&geometries, &boundary).unwrap();
679
680        assert!(result.utilization > 0.0);
681        assert!(result.placements.len() <= 5);
682    }
683
684    #[test]
685    fn test_mass_constraint() {
686        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
687            .with_quantity(10)
688            .with_mass(100.0)];
689
690        let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
691
692        let packer = Packer3D::default_config();
693        let result = packer.solve(&geometries, &boundary).unwrap();
694
695        // Should only place 3 boxes (300 mass) due to 350 mass limit
696        assert!(result.placements.len() <= 3);
697    }
698
699    #[test]
700    fn test_placement_within_bounds() {
701        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
702
703        let boundary = Boundary3D::new(50.0, 50.0, 50.0);
704        let config = Config::default().with_margin(5.0).with_spacing(2.0);
705        let packer = Packer3D::new(config);
706
707        let result = packer.solve(&geometries, &boundary).unwrap();
708
709        // All boxes should be placed
710        assert_eq!(result.placements.len(), 4);
711        assert!(result.unplaced.is_empty());
712
713        // Verify placements are within bounds (with margin)
714        for p in &result.placements {
715            assert!(p.position[0] >= 5.0);
716            assert!(p.position[1] >= 5.0);
717            assert!(p.position[2] >= 5.0);
718        }
719    }
720
721    #[test]
722    fn test_ga_strategy_basic() {
723        let geometries = vec![
724            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
725            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
726        ];
727
728        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
729        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
730        let packer = Packer3D::new(config);
731
732        let result = packer.solve(&geometries, &boundary).unwrap();
733
734        // GA should place items and achieve positive utilization
735        assert!(result.utilization > 0.0);
736        assert!(!result.placements.is_empty());
737    }
738
739    #[test]
740    fn test_ga_strategy_all_placed() {
741        // Small number of boxes that should all fit
742        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
743
744        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
745        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
746        let packer = Packer3D::new(config);
747
748        let result = packer.solve(&geometries, &boundary).unwrap();
749
750        // All 4 boxes should be placed
751        assert_eq!(result.placements.len(), 4);
752        assert!(result.unplaced.is_empty());
753    }
754
755    #[test]
756    #[cfg(feature = "serde")]
757    fn test_3d_response_orientation_in_bounds() {
758        use crate::build_pack3d_response;
759        use crate::geometry::OrientationConstraint;
760
761        // A tall box in a flat container can only be placed rotated. The response must
762        // report the orientation that was actually used, so reconstructing the box
763        // footprint from the reported orientation label keeps it inside the boundary.
764        // Pre-fix, EP/GA/BRKGA dropped the orientation and reported "xyz" (identity),
765        // making rotated placements read as out-of-bounds (the 0.3.3 "oob" reports).
766        let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
767        let boundary = Boundary3D::new(bw, bd, bh);
768
769        for strategy in [
770            Strategy::ExtremePoint,
771            Strategy::GeneticAlgorithm,
772            Strategy::Brkga,
773        ] {
774            let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
775                .with_quantity(3)
776                .with_orientation(OrientationConstraint::Any)];
777            let config = Config::default().with_strategy(strategy);
778            let packer = Packer3D::new(config);
779            let result = packer.solve(&geometries, &boundary).unwrap();
780            let response = build_pack3d_response(&result, &geometries);
781
782            // EP is deterministic and must place at least one box (only fits rotated).
783            if strategy == Strategy::ExtremePoint {
784                assert!(
785                    !response.placements.is_empty(),
786                    "EP should place the tall box by rotating it into the flat container"
787                );
788            }
789
790            let base = geometries[0].dimensions_for_orientation(0); // unrotated dims
791            for p in &response.placements {
792                let axes: Vec<usize> = p
793                    .orientation
794                    .chars()
795                    .map(|c| match c {
796                        'x' => 0,
797                        'y' => 1,
798                        'z' => 2,
799                        _ => panic!("unexpected orientation label '{}'", p.orientation),
800                    })
801                    .collect();
802                let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
803                let e = 1e-6;
804                assert!(
805                    p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
806                    "{:?} {}#{} out of bounds for reported orientation '{}': \
807                     pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
808                    strategy,
809                    p.geometry_id,
810                    p.instance,
811                    p.orientation,
812                    p.x,
813                    p.y,
814                    p.z,
815                );
816            }
817        }
818    }
819
820    #[test]
821    fn test_ga_strategy_with_orientations() {
822        use crate::geometry::OrientationConstraint;
823
824        // Box that fits better when rotated
825        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
826            .with_quantity(2)
827            .with_orientation(OrientationConstraint::Any)];
828
829        // Container where orientation matters
830        let boundary = Boundary3D::new(60.0, 60.0, 60.0);
831        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
832        let packer = Packer3D::new(config);
833
834        let result = packer.solve(&geometries, &boundary).unwrap();
835
836        // GA should find a way to place both boxes
837        assert_eq!(result.placements.len(), 2);
838    }
839
840    #[test]
841    fn test_brkga_strategy_basic() {
842        let geometries = vec![
843            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
844            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
845        ];
846
847        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
848        let config = Config::default().with_strategy(Strategy::Brkga);
849        let packer = Packer3D::new(config);
850
851        let result = packer.solve(&geometries, &boundary).unwrap();
852
853        // BRKGA should place items and achieve positive utilization
854        assert!(result.utilization > 0.0);
855        assert!(!result.placements.is_empty());
856        assert_eq!(result.strategy, Some("BRKGA".to_string()));
857    }
858
859    #[test]
860    fn test_brkga_strategy_all_placed() {
861        // Small number of boxes that should all fit
862        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
863
864        let boundary = Boundary3D::new(100.0, 100.0, 100.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        // All 4 boxes should be placed
871        assert_eq!(result.placements.len(), 4);
872        assert!(result.unplaced.is_empty());
873    }
874
875    #[test]
876    fn test_ep_strategy_basic() {
877        let geometries = vec![
878            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
879            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
880        ];
881
882        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
883        let config = Config::default().with_strategy(Strategy::ExtremePoint);
884        let packer = Packer3D::new(config);
885
886        let result = packer.solve(&geometries, &boundary).unwrap();
887
888        // EP should place items and achieve positive utilization
889        assert!(result.utilization > 0.0);
890        assert!(!result.placements.is_empty());
891        assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
892    }
893
894    #[test]
895    fn test_ep_strategy_all_placed() {
896        // Small number of boxes that should all fit
897        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
898
899        let boundary = Boundary3D::new(100.0, 100.0, 100.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        // All 4 boxes should be placed
906        assert_eq!(result.placements.len(), 4);
907        assert!(result.unplaced.is_empty());
908    }
909
910    #[test]
911    fn test_ep_strategy_with_margin() {
912        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
913
914        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
915        let config = Config::default()
916            .with_strategy(Strategy::ExtremePoint)
917            .with_margin(5.0);
918        let packer = Packer3D::new(config);
919
920        let result = packer.solve(&geometries, &boundary).unwrap();
921
922        // Verify placements start at margin
923        for p in &result.placements {
924            assert!(p.position[0] >= 4.9);
925            assert!(p.position[1] >= 4.9);
926            assert!(p.position[2] >= 4.9);
927        }
928    }
929
930    #[test]
931    fn test_ep_strategy_with_orientations() {
932        use crate::geometry::OrientationConstraint;
933
934        // Long box that benefits from rotation
935        let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
936            .with_quantity(2)
937            .with_orientation(OrientationConstraint::Any)];
938
939        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
940        let config = Config::default().with_strategy(Strategy::ExtremePoint);
941        let packer = Packer3D::new(config);
942
943        let result = packer.solve(&geometries, &boundary).unwrap();
944
945        // EP should find a way to place both boxes
946        assert_eq!(result.placements.len(), 2);
947    }
948
949    #[test]
950    fn test_ep_strategy_perfect_fill_via_solve() {
951        // End-to-end through Packer3D::solve (the path FFI/WASM use): eight 50-cubes
952        // tile a 100^3 container exactly. EP must place all 8. Guards the under-packing
953        // regression at the dispatch level, not just run_ep_packing.
954        let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
955        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
956        let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
957
958        let result = packer.solve(&geometries, &boundary).unwrap();
959
960        assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
961        assert!(result.unplaced.is_empty());
962    }
963
964    #[test]
965    fn test_ep_at_least_as_good_as_blf() {
966        // A correct Extreme-Point heuristic never places fewer boxes than the simpler
967        // layer (BLF) strategy on the same instance. Before the fix EP stalled far below
968        // BLF (e.g. 4 vs 13 on the mixed-box set). Covers several shapes.
969        let boundary = Boundary3D::new(85.0, 85.0, 80.0);
970        let cases: [(&str, f64, f64, f64, usize); 3] = [
971            ("big", 40.0, 40.0, 40.0, 4),
972            ("mid", 30.0, 20.0, 25.0, 6),
973            ("small", 15.0, 15.0, 30.0, 8),
974        ];
975        let geometries: Vec<Geometry3D> = cases
976            .iter()
977            .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
978            .collect();
979
980        let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
981            .solve(&geometries, &boundary)
982            .unwrap();
983        let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
984            .solve(&geometries, &boundary)
985            .unwrap();
986
987        assert!(
988            ep.placements.len() >= blf.placements.len(),
989            "EP placed {} but BLF placed {} — EP must not regress below BLF",
990            ep.placements.len(),
991            blf.placements.len()
992        );
993    }
994
995    /// Builds a result whose second box floats above the floor with nothing beneath it.
996    fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
997        let geometries = vec![
998            Geometry3D::new("a", 20.0, 20.0, 20.0),
999            Geometry3D::new("b", 20.0, 20.0, 20.0),
1000        ];
1001        let mut result = SolveResult::new();
1002        result.placements.push(
1003            Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1004                .with_rotation_index(0),
1005        );
1006        // "b" hovers at z=50 — no floor contact, no box below.
1007        result.placements.push(
1008            Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1009                .with_rotation_index(0),
1010        );
1011        (geometries, result)
1012    }
1013
1014    #[test]
1015    fn test_gravity_removes_floating_box() {
1016        let (geometries, mut result) = floating_pair();
1017        let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1018        let packer = Packer3D::default_config();
1019
1020        packer.enforce_support(&mut result, &geometries, &boundary);
1021
1022        let ids: Vec<&str> = result
1023            .placements
1024            .iter()
1025            .map(|p| p.geometry_id.as_str())
1026            .collect();
1027        assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1028        assert!(result.unplaced.iter().any(|id| id == "b"));
1029    }
1030
1031    #[test]
1032    fn test_no_constraint_keeps_floating_box() {
1033        // Without gravity/stability the solver never calls enforce_support, so a result
1034        // with a floating box is returned untouched. Verify enforce_support is the only
1035        // thing that would remove it — i.e. solve() leaves such input alone.
1036        let (geometries, result) = floating_pair();
1037        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1038        assert!(!boundary.has_gravity() && !boundary.has_stability());
1039        // The pair was hand-built (not via solve); just assert the boundary flags gate it.
1040        assert_eq!(result.placements.len(), 2);
1041        let _ = geometries;
1042    }
1043
1044    #[test]
1045    fn test_gravity_keeps_floor_boxes_with_margin() {
1046        // With a wall margin the pack starts boxes at z = margin, which the support
1047        // analysis must treat as the floor. If it checks against z = 0 instead, every
1048        // bottom-layer box reads as floating and the whole pack gets dropped. End-to-end
1049        // through solve() with margin > 0 and gravity on.
1050        let geometries = vec![
1051            Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1052            Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1053        ];
1054        let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1055        let packer = Packer3D::new(
1056            Config::default()
1057                .with_margin(5.0)
1058                .with_strategy(Strategy::BottomLeftFill),
1059        );
1060
1061        let result = packer.solve(&geometries, &boundary).unwrap();
1062
1063        assert!(
1064            !result.placements.is_empty(),
1065            "margin>0 + gravity must not drop floor-resting boxes"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_stability_drops_undersupported_box() {
1071        // "b" rests on "a" but overlaps only a thin sliver of its top face (well under
1072        // the 70% base-support threshold). Gravity alone keeps it (it does touch below);
1073        // stability drops it.
1074        let geometries = vec![
1075            Geometry3D::new("a", 40.0, 40.0, 20.0),
1076            Geometry3D::new("b", 40.0, 40.0, 20.0),
1077        ];
1078        let make = || {
1079            let mut r = SolveResult::new();
1080            r.placements.push(
1081                Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1082                    .with_rotation_index(0),
1083            );
1084            // shifted so only a 5x40 strip (≈12.5%) of b's base sits on a's top
1085            r.placements.push(
1086                Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1087                    .with_rotation_index(0),
1088            );
1089            r
1090        };
1091        let packer = Packer3D::default_config();
1092
1093        let mut grav = make();
1094        packer.enforce_support(
1095            &mut grav,
1096            &geometries,
1097            &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1098        );
1099        assert_eq!(
1100            grav.placements.len(),
1101            2,
1102            "gravity keeps a box that touches below"
1103        );
1104
1105        let mut stab = make();
1106        packer.enforce_support(
1107            &mut stab,
1108            &geometries,
1109            &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1110        );
1111        assert!(
1112            stab.placements.iter().all(|p| p.geometry_id == "a"),
1113            "stability drops the under-supported box"
1114        );
1115    }
1116
1117    #[test]
1118    fn test_layer_packing_orientation_optimization() {
1119        use crate::geometry::OrientationConstraint;
1120
1121        // A box 50x10x10 that won't fit in 45 width without rotation
1122        // But at orientation (1,0,2) it becomes 10x50x10, width=10, which fits
1123        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1124            .with_quantity(2)
1125            .with_orientation(OrientationConstraint::Any)];
1126
1127        // Narrow container: width=45, depth=80, height=80
1128        let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1129        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1130        let packer = Packer3D::new(config);
1131
1132        let result = packer.solve(&geometries, &boundary).unwrap();
1133
1134        // Both boxes should be placed via orientation change
1135        assert_eq!(
1136            result.placements.len(),
1137            2,
1138            "Both boxes should be placed by using rotation"
1139        );
1140        assert!(result.unplaced.is_empty());
1141
1142        // Verify orientation index is set for placements
1143        for p in &result.placements {
1144            assert!(
1145                p.rotation_index.is_some(),
1146                "Placement should have rotation_index set"
1147            );
1148        }
1149    }
1150
1151    #[test]
1152    fn test_layer_packing_selects_best_orientation() {
1153        use crate::geometry::OrientationConstraint;
1154
1155        // Box 30x20x10 in container 35x50x100
1156        // Original orientation (30x20x10): fits in row, leaves 5 spare width
1157        // Rotated (20x30x10): fits but uses more depth
1158        // Best: original orientation to minimize vertical space usage
1159        let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1160            .with_quantity(1)
1161            .with_orientation(OrientationConstraint::Any)];
1162
1163        let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1164        let packer = Packer3D::default_config();
1165
1166        let result = packer.solve(&geometries, &boundary).unwrap();
1167
1168        assert_eq!(result.placements.len(), 1);
1169        assert!(result.unplaced.is_empty());
1170    }
1171}