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