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