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, 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
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        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    /// The fitness a search stops at for `target_utilization`: every box
101    /// placed, at that utilization ([`packing_fitness`] of a complete packing).
102    fn target_fitness(&self) -> Option<f64> {
103        self.config
104            .target_utilization
105            .map(|target| packing_fitness(1, 1, target))
106    }
107
108    /// The BRKGA run the configuration asks for: the caller's population and
109    /// generation budget, [`BRKGA_POPULATION`] / [`BRKGA_GENERATIONS`] for the
110    /// ones left unset.
111    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    /// Judges the request once, at the solve entry point, whichever strategy
125    /// runs: the configuration, the boundary, the geometries -- an empty list
126    /// is `empty_input`, there is nothing to pack -- and their ids.
127    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    /// Validates the stability of a packing result.
140    ///
141    /// Analyzes each placement to ensure boxes are properly supported.
142    ///
143    /// # Errors
144    ///
145    /// The refusal of [`StabilityConstraint::validate`].
146    pub fn validate_stability(
147        &self,
148        result: &SolveResult<f64>,
149        geometries: &[Geometry3D],
150        _boundary: &Boundary3D,
151        constraint: StabilityConstraint,
152    ) -> Result<StabilityReport> {
153        // Convert placements to PlacedBox format
154        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    /// Validates stability using physics simulation.
160    ///
161    /// Runs a physics simulation to detect boxes that would fall or tip.
162    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    /// Enforces the boundary's gravity/stability constraints on a finished packing.
178    ///
179    /// Strategies optimise for volume, not support, so a result may contain boxes that
180    /// float or rest on too little base. When the boundary requests gravity and/or
181    /// stability, this removes the offending boxes (moving them to `unplaced`) so the
182    /// returned packing actually satisfies the constraint the caller asked for — rather
183    /// than the flags being silently ignored.
184    ///
185    /// Removal iterates: dropping a box can leave the boxes it supported unsupported, so
186    /// the result is re-validated until every remaining box is stable.
187    fn enforce_support(
188        &self,
189        result: &mut SolveResult<f64>,
190        geometries: &[Geometry3D],
191        boundary: &Boundary3D,
192    ) {
193        // Stability is the stronger requirement (a real base-support ratio); plain
194        // gravity only forbids floating, i.e. demands any positive contact below.
195        let constraint = if boundary.has_stability() {
196            StabilityConstraint::partial_base(0.7)
197        } else {
198            StabilityConstraint::partial_base(1e-6)
199        };
200
201        // The pack rests boxes on z = margin (the inset floor), so the support analysis
202        // must use that as the floor — checking against z = 0 would read every bottom
203        // box as floating and drop the whole pack when margin > 0.
204        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        // Placed volume shrank, so the reported utilization must follow.
228        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    /// Converts placements to PlacedBox format for stability analysis.
242    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    /// Layer packing: every box in request order, each in its best orientation
275    /// (see [`layer_fill_in_order`]), within the time limit.
276    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    /// The layer fill, reporting progress to `callback` when there is one:
285    /// once before, once per box placed and once at the end.
286    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    /// The time a search may run after the layer fill it starts from:
355    /// what is left of the limit, `None` when there is no limit.
356    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    /// Returns the search's packing unless a simple fill -- the layer fill it
368    /// started from, or the extreme-point fill -- packs better: more boxes,
369    /// then a higher utilization. Packings are compared as the caller gets
370    /// them, after gravity and stability have dropped the boxes they do not
371    /// allow, since a search scores its packings before that.
372    ///
373    /// A search can stop, on a small population, generation or time budget,
374    /// at a packing worse than the one pass of a simple fill; flooring it here
375    /// means a search never returns less. A tie keeps the search's packing.
376    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    /// Genetic Algorithm based packing optimization.
403    ///
404    /// Optimizes placement order and orientations with layer-based decoding,
405    /// starting from the layer fill and floored at the simple fills.
406    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    /// BRKGA (Biased Random-Key Genetic Algorithm) based packing optimization.
427    ///
428    /// Random-key encoding and biased crossover, starting from the layer fill
429    /// and floored at the simple fills.
430    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    /// Simulated Annealing based packing optimization.
447    ///
448    /// Neighborhood moves with temperature-based acceptance, starting from
449    /// the layer fill and floored at the simple fills.
450    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    /// Extreme Point heuristic-based packing.
478    ///
479    /// Places boxes at extreme points (positions touching at least two surfaces).
480    /// More efficient than layer-based packing for many scenarios.
481    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        // Convert EP placements to Placement structs. The orientation index must be
497        // preserved via `rotation_index` so the response can report the actual box
498        // footprint; dropping it made every EP placement report the identity
499        // orientation, which read as out-of-bounds for rotated boxes downstream.
500        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, // rotation_x
504                0.0, // rotation_y
505                0.0, // rotation_z (orientation encoded via rotation_index)
506            )
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        // Reset cancellation flag
538        self.cancelled.store(false, Ordering::Relaxed);
539
540        let mut result = self.run_strategy(geometries, boundary)?;
541
542        // Remove duplicate entries from unplaced list
543        result.deduplicate_unplaced();
544        // Authoritative instance-level request total (Σ quantity), recorded once
545        // at the top-level entry point where `geometries` is the full request.
546        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
547
548        // Honor gravity/stability constraints, if requested, on the finished packing.
549        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        // Reset cancellation flag
564        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            // EP is a fast single pass; run the real heuristic (not layer packing) and
569            // skip incremental progress rather than silently substituting BLF.
570            Strategy::ExtremePoint => self.extreme_point(geometries, boundary)?,
571            // The other strategies have no progress reporting yet: run the
572            // strategy that was asked for, without progress, rather than a
573            // different one that reports it.
574            _ => self.run_strategy(geometries, boundary)?,
575        };
576
577        // Remove duplicate entries from unplaced list
578        result.deduplicate_unplaced();
579        // Authoritative instance-level request total (Σ quantity), recorded once
580        // at the top-level entry point where `geometries` is the full request.
581        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
582
583        // Honor gravity/stability constraints, if requested, on the finished packing.
584        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    /// Nothing to pack is refused, and every entry point judges the whole
621    /// request the same way -- the progress entry point used to skip the
622    /// configuration, and no entry point judged the boxes before the strategy
623    /// ran.
624    #[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    /// A population or generation budget the caller sets is the one the
664    /// search runs with -- BRKGA used to run 50 x 100 whatever was asked;
665    /// left unset, each search runs with its own.
666    #[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        // Should only place 3 boxes (300 mass) due to 350 mass limit
713        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        // All boxes should be placed
727        assert_eq!(result.placements.len(), 4);
728        assert!(result.unplaced.is_empty());
729
730        // Verify placements are within bounds (with margin)
731        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        // GA should place items and achieve positive utilization
752        assert!(result.utilization > 0.0);
753        assert!(!result.placements.is_empty());
754    }
755
756    #[test]
757    fn test_ga_strategy_all_placed() {
758        // Small number of boxes that should all fit
759        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        // All 4 boxes should be placed
768        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        // A tall box in a flat container can only be placed rotated. The response must
779        // report the orientation that was actually used, so reconstructing the box
780        // footprint from the reported orientation label keeps it inside the boundary.
781        // Pre-fix, EP/GA/BRKGA dropped the orientation and reported "xyz" (identity),
782        // making rotated placements read as out-of-bounds (the 0.3.3 "oob" reports).
783        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            // EP is deterministic and must place at least one box (only fits rotated).
800            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); // unrotated dims
808            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        // Box that fits better when rotated
842        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
843            .with_quantity(2)
844            .with_orientation(OrientationConstraint::Any)];
845
846        // Container where orientation matters
847        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        // GA should find a way to place both boxes
854        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        // BRKGA should place items and achieve positive utilization
871        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        // Small number of boxes that should all fit
879        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        // All 4 boxes should be placed
888        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        // EP should place items and achieve positive utilization
906        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        // Small number of boxes that should all fit
914        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        // All 4 boxes should be placed
923        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        // Verify placements start at margin
940        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        // Long box that benefits from rotation
952        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        // EP should find a way to place both boxes
963        assert_eq!(result.placements.len(), 2);
964    }
965
966    #[test]
967    fn test_ep_strategy_perfect_fill_via_solve() {
968        // End-to-end through Packer3D::solve (the path FFI/WASM use): eight 50-cubes
969        // tile a 100^3 container exactly. EP must place all 8. Guards the under-packing
970        // regression at the dispatch level, not just run_ep_packing.
971        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        // A correct Extreme-Point heuristic never places fewer boxes than the simpler
984        // layer (BLF) strategy on the same instance. Before the fix EP stalled far below
985        // BLF (e.g. 4 vs 13 on the mixed-box set). Covers several shapes.
986        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    /// Builds a result whose second box floats above the floor with nothing beneath it.
1013    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        // "b" hovers at z=50 — no floor contact, no box below.
1024        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        // Without gravity/stability the solver never calls enforce_support, so a result
1051        // with a floating box is returned untouched. Verify enforce_support is the only
1052        // thing that would remove it — i.e. solve() leaves such input alone.
1053        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        // The pair was hand-built (not via solve); just assert the boundary flags gate it.
1057        assert_eq!(result.placements.len(), 2);
1058        let _ = geometries;
1059    }
1060
1061    #[test]
1062    fn test_gravity_keeps_floor_boxes_with_margin() {
1063        // With a wall margin the pack starts boxes at z = margin, which the support
1064        // analysis must treat as the floor. If it checks against z = 0 instead, every
1065        // bottom-layer box reads as floating and the whole pack gets dropped. End-to-end
1066        // through solve() with margin > 0 and gravity on.
1067        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        // "b" rests on "a" but overlaps only a thin sliver of its top face (well under
1089        // the 70% base-support threshold). Gravity alone keeps it (it does touch below);
1090        // stability drops it.
1091        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            // shifted so only a 5x40 strip (≈12.5%) of b's base sits on a's top
1102            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        // A box 50x10x10 that won't fit in 45 width without rotation
1139        // But at orientation (1,0,2) it becomes 10x50x10, width=10, which fits
1140        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1141            .with_quantity(2)
1142            .with_orientation(OrientationConstraint::Any)];
1143
1144        // Narrow container: width=45, depth=80, height=80
1145        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        // Both boxes should be placed via orientation change
1152        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        // Verify orientation index is set for placements
1160        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        // Box 30x20x10 in container 35x50x100
1173        // Original orientation (30x20x10): fits in row, leaves 5 spare width
1174        // Rotated (20x30x10): fits but uses more depth
1175        // Best: original orientation to minimize vertical space usage
1176        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}