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            // The objective is `100 x placed share + 10 x utilisation`, so a layout a
531            // few percent worse differs by a few tenths: a temperature of 100 down to
532            // 0.1 accepts nearly every candidate and the search is a random walk.
533            // Start where a tenth worse is accepted about one time in three and
534            // cool to where a hundredth rarely is, over the iteration budget.
535            // Absolute, not calibrated: one box more or less moves the objective by
536            // about 4 while the tuning that matters moves it by tenths, and a scale
537            // measured on neighbours of the start reads the 4 -- a start ten times
538            // too hot (8 random sets of 25 boxes: utilisation 75.5 % against 70-73 %).
539            .with_initial_temp(0.5)
540            .with_final_temp(0.01)
541            .with_cooling_rate(0.98)
542            .with_iterations_per_temp(50)
543            .with_max_iterations(self.config.max_iterations.unwrap_or(10000));
544        sa_config.time_limit = self.search_time_limit(&started);
545        sa_config.target_fitness = self.target_fitness();
546
547        let result = run_sa_packing(
548            geometries,
549            boundary,
550            &self.config,
551            sa_config,
552            self.cancelled.clone(),
553            Some(&fill.placements),
554            progress,
555        )?;
556        Ok(self.not_worse_than_fills(result, geometries, boundary, fill))
557    }
558
559    /// Extreme Point heuristic-based packing.
560    ///
561    /// Places boxes at extreme points (positions touching at least two surfaces).
562    /// More efficient than layer-based packing for many scenarios.
563    fn extreme_point(
564        &self,
565        geometries: &[Geometry3D],
566        boundary: &Boundary3D,
567        progress: Option<&ProgressCallback>,
568    ) -> Result<SolveResult<f64>> {
569        let start = Timer::now();
570        let total: usize = geometries.iter().map(|g| g.quantity()).sum();
571        let report = |info: ProgressInfo| {
572            if let Some(callback) = progress {
573                callback(info.with_phase("Extreme Point"));
574            }
575        };
576
577        let mut stop = None;
578        let (ep_placements, utilization) = run_ep_packing(
579            geometries,
580            boundary,
581            self.config.margin,
582            self.config.spacing,
583            boundary.max_mass(),
584            || {
585                // Asked before each box: where the run hears of a cancel.
586                report(
587                    ProgressInfo::new()
588                        .with_items(0, total)
589                        .with_elapsed(start.elapsed_ms()),
590                );
591                stop = self.stop_requested(&start);
592                stop.is_some()
593            },
594        );
595
596        // Convert EP placements to Placement structs. The orientation index must be
597        // preserved via `rotation_index` so the response can report the actual box
598        // footprint; dropping it made every EP placement report the identity
599        // orientation, which read as out-of-bounds for rotated boxes downstream.
600        let mut placements = Vec::new();
601        for (id, instance, position, orientation) in ep_placements {
602            let placement = Placement::new_3d(
603                id, instance, position.x, position.y, position.z, 0.0, // rotation_x
604                0.0, // rotation_y
605                0.0, // rotation_z (orientation encoded via rotation_index)
606            )
607            .with_rotation_index(orientation);
608            placements.push(placement);
609        }
610
611        let unplaced = build_unplaced_list(&placements, geometries);
612
613        let mut result = SolveResult::new();
614        result.boundaries_used = usize::from(!placements.is_empty());
615        result.placements = placements;
616        result.utilization = utilization;
617        if let Some(stop) = stop {
618            result.stop_reason = stop;
619        }
620        result.unplaced = unplaced;
621        result.computation_time_ms = start.elapsed_ms();
622        result.strategy = Some("ExtremePoint".to_string());
623        report(
624            ProgressInfo::new()
625                .with_items(result.placements.len(), total)
626                .with_utilization(result.utilization)
627                .with_elapsed(result.computation_time_ms)
628                .finished(),
629        );
630
631        Ok(result)
632    }
633}
634
635impl Solver for Packer3D {
636    type Geometry = Geometry3D;
637    type Boundary = Boundary3D;
638    type Scalar = f64;
639
640    fn solve(
641        &self,
642        geometries: &[Self::Geometry],
643        boundary: &Self::Boundary,
644    ) -> Result<SolveResult<f64>> {
645        self.solve_reporting(geometries, boundary, None)
646    }
647
648    fn solve_with_progress(
649        &self,
650        geometries: &[Self::Geometry],
651        boundary: &Self::Boundary,
652        callback: ProgressCallback,
653    ) -> Result<SolveResult<f64>> {
654        self.solve_reporting(geometries, boundary, Some(&callback))
655    }
656
657    fn cancel(&self) {
658        self.cancelled.store(true, Ordering::Relaxed);
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
668        let geometries = [
669            Geometry3D::new("B", 10.0, 10.0, 10.0),
670            Geometry3D::new("B", 50.0, 50.0, 50.0),
671        ];
672        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
673        let packer = Packer3D::default_config();
674        let errors = [
675            packer.solve(&geometries, &boundary).expect_err("solve"),
676            packer
677                .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
678                .expect_err("solve_with_progress"),
679        ];
680        for err in errors {
681            let msg = err.to_string();
682            assert!(msg.contains("'B'"), "{msg}");
683            assert!(msg.contains("positions 0 and 1"), "{msg}");
684        }
685    }
686
687    /// Nothing to pack is refused, and every entry point judges the whole
688    /// request the same way -- the progress entry point used to skip the
689    /// configuration, and no entry point judged the boxes before the strategy
690    /// ran.
691    #[test]
692    fn every_entry_point_judges_the_whole_request() {
693        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
694        let packer = Packer3D::default_config();
695        let empty = Error::EmptyInput {
696            parameter: "geometries".into(),
697        };
698        assert_eq!(packer.solve(&[], &boundary).expect_err("solve"), empty);
699        assert_eq!(
700            packer
701                .solve_with_progress(&[], &boundary, Box::new(|_| {}))
702                .expect_err("solve_with_progress"),
703            empty
704        );
705
706        let boxes = [Geometry3D::new("B", 10.0, 10.0, 10.0)];
707        let bad_config = Packer3D::new(Config::default().with_spacing(-1.0));
708        for err in [
709            bad_config.solve(&boxes, &boundary).expect_err("solve"),
710            bad_config
711                .solve_with_progress(&boxes, &boundary, Box::new(|_| {}))
712                .expect_err("solve_with_progress"),
713        ] {
714            assert_eq!(err.code(), "parameter_out_of_range", "{err}");
715        }
716
717        let flat = [Geometry3D::new("flat", 10.0, 0.0, 10.0)];
718        for strategy in [Strategy::ExtremePoint, Strategy::GeneticAlgorithm] {
719            let packer = Packer3D::new(Config::default().with_strategy(strategy));
720            match packer.solve(&flat, &boundary) {
721                Err(Error::OutOfRange { parameter, id, .. }) => {
722                    assert_eq!(parameter, "dimensions[1]");
723                    assert_eq!(id.as_deref(), Some("flat"));
724                }
725                other => panic!("{strategy:?}: {other:?}"),
726            }
727        }
728    }
729
730    /// A population or generation budget the caller sets is the one the
731    /// search runs with -- BRKGA used to run 50 x 100 whatever was asked;
732    /// left unset, each search runs with its own.
733    #[test]
734    fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
735        let unset = Packer3D::default_config();
736        assert_eq!(unset.ga_config().population_size, 100);
737        assert_eq!(unset.ga_config().max_generations, 500);
738        assert_eq!(unset.brkga_config().population_size, 50);
739        assert_eq!(unset.brkga_config().max_generations, 100);
740
741        let set = Packer3D::new(
742            Config::default()
743                .with_population_size(12)
744                .with_max_generations(3),
745        );
746        assert_eq!(set.ga_config().population_size, 12);
747        assert_eq!(set.ga_config().max_generations, 3);
748        assert_eq!(set.brkga_config().population_size, 12);
749        assert_eq!(set.brkga_config().max_generations, 3);
750    }
751
752    #[test]
753    fn test_simple_packing() {
754        let geometries = vec![
755            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
756            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
757        ];
758
759        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
760        let packer = Packer3D::default_config();
761
762        let result = packer.solve(&geometries, &boundary).unwrap();
763
764        assert!(result.utilization > 0.0);
765        assert!(result.placements.len() <= 5);
766    }
767
768    #[test]
769    fn test_mass_constraint() {
770        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
771            .with_quantity(10)
772            .with_mass(100.0)];
773
774        let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
775
776        let packer = Packer3D::default_config();
777        let result = packer.solve(&geometries, &boundary).unwrap();
778
779        // Should only place 3 boxes (300 mass) due to 350 mass limit
780        assert!(result.placements.len() <= 3);
781    }
782
783    #[test]
784    fn test_placement_within_bounds() {
785        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
786
787        let boundary = Boundary3D::new(50.0, 50.0, 50.0);
788        let config = Config::default().with_margin(5.0).with_spacing(2.0);
789        let packer = Packer3D::new(config);
790
791        let result = packer.solve(&geometries, &boundary).unwrap();
792
793        // All boxes should be placed
794        assert_eq!(result.placements.len(), 4);
795        assert!(result.unplaced.is_empty());
796
797        // Verify placements are within bounds (with margin)
798        for p in &result.placements {
799            assert!(p.position[0] >= 5.0);
800            assert!(p.position[1] >= 5.0);
801            assert!(p.position[2] >= 5.0);
802        }
803    }
804
805    #[test]
806    fn test_ga_strategy_basic() {
807        let geometries = vec![
808            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
809            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
810        ];
811
812        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
813        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
814        let packer = Packer3D::new(config);
815
816        let result = packer.solve(&geometries, &boundary).unwrap();
817
818        // GA should place items and achieve positive utilization
819        assert!(result.utilization > 0.0);
820        assert!(!result.placements.is_empty());
821    }
822
823    #[test]
824    fn test_ga_strategy_all_placed() {
825        // Small number of boxes that should all fit
826        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
827
828        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
829        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
830        let packer = Packer3D::new(config);
831
832        let result = packer.solve(&geometries, &boundary).unwrap();
833
834        // All 4 boxes should be placed
835        assert_eq!(result.placements.len(), 4);
836        assert!(result.unplaced.is_empty());
837    }
838
839    #[test]
840    #[cfg(feature = "serde")]
841    fn test_3d_response_orientation_in_bounds() {
842        use crate::build_pack3d_response;
843        use crate::geometry::OrientationConstraint;
844
845        // A tall box in a flat container can only be placed rotated. The response must
846        // report the orientation that was actually used, so reconstructing the box
847        // footprint from the reported orientation label keeps it inside the boundary.
848        // Pre-fix, EP/GA/BRKGA dropped the orientation and reported "xyz" (identity),
849        // making rotated placements read as out-of-bounds (the 0.3.3 "oob" reports).
850        let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
851        let boundary = Boundary3D::new(bw, bd, bh);
852
853        for strategy in [
854            Strategy::ExtremePoint,
855            Strategy::GeneticAlgorithm,
856            Strategy::Brkga,
857        ] {
858            let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
859                .with_quantity(3)
860                .with_orientation(OrientationConstraint::Any)];
861            let config = Config::default().with_strategy(strategy);
862            let packer = Packer3D::new(config);
863            let result = packer.solve(&geometries, &boundary).unwrap();
864            let response = build_pack3d_response(&result, &geometries);
865
866            // EP is deterministic and must place at least one box (only fits rotated).
867            if strategy == Strategy::ExtremePoint {
868                assert!(
869                    !response.placements.is_empty(),
870                    "EP should place the tall box by rotating it into the flat container"
871                );
872            }
873
874            let base = geometries[0].dimensions_for_orientation(0); // unrotated dims
875            for p in &response.placements {
876                let axes: Vec<usize> = p
877                    .orientation
878                    .chars()
879                    .map(|c| match c {
880                        'x' => 0,
881                        'y' => 1,
882                        'z' => 2,
883                        _ => panic!("unexpected orientation label '{}'", p.orientation),
884                    })
885                    .collect();
886                let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
887                let e = 1e-6;
888                assert!(
889                    p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
890                    "{:?} {}#{} out of bounds for reported orientation '{}': \
891                     pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
892                    strategy,
893                    p.geometry_id,
894                    p.instance,
895                    p.orientation,
896                    p.x,
897                    p.y,
898                    p.z,
899                );
900            }
901        }
902    }
903
904    #[test]
905    fn test_ga_strategy_with_orientations() {
906        use crate::geometry::OrientationConstraint;
907
908        // Box that fits better when rotated
909        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
910            .with_quantity(2)
911            .with_orientation(OrientationConstraint::Any)];
912
913        // Container where orientation matters
914        let boundary = Boundary3D::new(60.0, 60.0, 60.0);
915        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
916        let packer = Packer3D::new(config);
917
918        let result = packer.solve(&geometries, &boundary).unwrap();
919
920        // GA should find a way to place both boxes
921        assert_eq!(result.placements.len(), 2);
922    }
923
924    #[test]
925    fn test_brkga_strategy_basic() {
926        let geometries = vec![
927            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
928            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
929        ];
930
931        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
932        let config = Config::default().with_strategy(Strategy::Brkga);
933        let packer = Packer3D::new(config);
934
935        let result = packer.solve(&geometries, &boundary).unwrap();
936
937        // BRKGA should place items and achieve positive utilization
938        assert!(result.utilization > 0.0);
939        assert!(!result.placements.is_empty());
940        assert_eq!(result.strategy, Some("BRKGA".to_string()));
941    }
942
943    #[test]
944    fn test_brkga_strategy_all_placed() {
945        // Small number of boxes that should all fit
946        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
947
948        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
949        let config = Config::default().with_strategy(Strategy::Brkga);
950        let packer = Packer3D::new(config);
951
952        let result = packer.solve(&geometries, &boundary).unwrap();
953
954        // All 4 boxes should be placed
955        assert_eq!(result.placements.len(), 4);
956        assert!(result.unplaced.is_empty());
957    }
958
959    #[test]
960    fn test_ep_strategy_basic() {
961        let geometries = vec![
962            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
963            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
964        ];
965
966        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
967        let config = Config::default().with_strategy(Strategy::ExtremePoint);
968        let packer = Packer3D::new(config);
969
970        let result = packer.solve(&geometries, &boundary).unwrap();
971
972        // EP should place items and achieve positive utilization
973        assert!(result.utilization > 0.0);
974        assert!(!result.placements.is_empty());
975        assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
976    }
977
978    #[test]
979    fn test_ep_strategy_all_placed() {
980        // Small number of boxes that should all fit
981        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
982
983        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
984        let config = Config::default().with_strategy(Strategy::ExtremePoint);
985        let packer = Packer3D::new(config);
986
987        let result = packer.solve(&geometries, &boundary).unwrap();
988
989        // All 4 boxes should be placed
990        assert_eq!(result.placements.len(), 4);
991        assert!(result.unplaced.is_empty());
992    }
993
994    #[test]
995    fn test_ep_strategy_with_margin() {
996        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
997
998        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
999        let config = Config::default()
1000            .with_strategy(Strategy::ExtremePoint)
1001            .with_margin(5.0);
1002        let packer = Packer3D::new(config);
1003
1004        let result = packer.solve(&geometries, &boundary).unwrap();
1005
1006        // Verify placements start at margin
1007        for p in &result.placements {
1008            assert!(p.position[0] >= 4.9);
1009            assert!(p.position[1] >= 4.9);
1010            assert!(p.position[2] >= 4.9);
1011        }
1012    }
1013
1014    #[test]
1015    fn test_ep_strategy_with_orientations() {
1016        use crate::geometry::OrientationConstraint;
1017
1018        // Long box that benefits from rotation
1019        let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
1020            .with_quantity(2)
1021            .with_orientation(OrientationConstraint::Any)];
1022
1023        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1024        let config = Config::default().with_strategy(Strategy::ExtremePoint);
1025        let packer = Packer3D::new(config);
1026
1027        let result = packer.solve(&geometries, &boundary).unwrap();
1028
1029        // EP should find a way to place both boxes
1030        assert_eq!(result.placements.len(), 2);
1031    }
1032
1033    #[test]
1034    fn test_ep_strategy_perfect_fill_via_solve() {
1035        // End-to-end through Packer3D::solve (the path FFI/WASM use): eight 50-cubes
1036        // tile a 100^3 container exactly. EP must place all 8. Guards the under-packing
1037        // regression at the dispatch level, not just run_ep_packing.
1038        let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
1039        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1040        let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
1041
1042        let result = packer.solve(&geometries, &boundary).unwrap();
1043
1044        assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
1045        assert!(result.unplaced.is_empty());
1046    }
1047
1048    #[test]
1049    fn test_ep_at_least_as_good_as_blf() {
1050        // A correct Extreme-Point heuristic never places fewer boxes than the simpler
1051        // layer (BLF) strategy on the same instance. Before the fix EP stalled far below
1052        // BLF (e.g. 4 vs 13 on the mixed-box set). Covers several shapes.
1053        let boundary = Boundary3D::new(85.0, 85.0, 80.0);
1054        let cases: [(&str, f64, f64, f64, usize); 3] = [
1055            ("big", 40.0, 40.0, 40.0, 4),
1056            ("mid", 30.0, 20.0, 25.0, 6),
1057            ("small", 15.0, 15.0, 30.0, 8),
1058        ];
1059        let geometries: Vec<Geometry3D> = cases
1060            .iter()
1061            .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
1062            .collect();
1063
1064        let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
1065            .solve(&geometries, &boundary)
1066            .unwrap();
1067        let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
1068            .solve(&geometries, &boundary)
1069            .unwrap();
1070
1071        assert!(
1072            ep.placements.len() >= blf.placements.len(),
1073            "EP placed {} but BLF placed {} — EP must not regress below BLF",
1074            ep.placements.len(),
1075            blf.placements.len()
1076        );
1077    }
1078
1079    /// Builds a result whose second box floats above the floor with nothing beneath it.
1080    fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
1081        let geometries = vec![
1082            Geometry3D::new("a", 20.0, 20.0, 20.0),
1083            Geometry3D::new("b", 20.0, 20.0, 20.0),
1084        ];
1085        let mut result = SolveResult::new();
1086        result.placements.push(
1087            Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1088                .with_rotation_index(0),
1089        );
1090        // "b" hovers at z=50 — no floor contact, no box below.
1091        result.placements.push(
1092            Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1093                .with_rotation_index(0),
1094        );
1095        (geometries, result)
1096    }
1097
1098    #[test]
1099    fn test_gravity_removes_floating_box() {
1100        let (geometries, mut result) = floating_pair();
1101        let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1102        let packer = Packer3D::default_config();
1103
1104        packer.enforce_support(&mut result, &geometries, &boundary);
1105
1106        let ids: Vec<&str> = result
1107            .placements
1108            .iter()
1109            .map(|p| p.geometry_id.as_str())
1110            .collect();
1111        assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1112        assert!(result.unplaced.iter().any(|id| id == "b"));
1113    }
1114
1115    #[test]
1116    fn test_no_constraint_keeps_floating_box() {
1117        // Without gravity/stability the solver never calls enforce_support, so a result
1118        // with a floating box is returned untouched. Verify enforce_support is the only
1119        // thing that would remove it — i.e. solve() leaves such input alone.
1120        let (geometries, result) = floating_pair();
1121        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1122        assert!(!boundary.has_gravity() && !boundary.has_stability());
1123        // The pair was hand-built (not via solve); just assert the boundary flags gate it.
1124        assert_eq!(result.placements.len(), 2);
1125        let _ = geometries;
1126    }
1127
1128    #[test]
1129    fn test_gravity_keeps_floor_boxes_with_margin() {
1130        // With a wall margin the pack starts boxes at z = margin, which the support
1131        // analysis must treat as the floor. If it checks against z = 0 instead, every
1132        // bottom-layer box reads as floating and the whole pack gets dropped. End-to-end
1133        // through solve() with margin > 0 and gravity on.
1134        let geometries = vec![
1135            Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1136            Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1137        ];
1138        let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1139        let packer = Packer3D::new(
1140            Config::default()
1141                .with_margin(5.0)
1142                .with_strategy(Strategy::BottomLeftFill),
1143        );
1144
1145        let result = packer.solve(&geometries, &boundary).unwrap();
1146
1147        assert!(
1148            !result.placements.is_empty(),
1149            "margin>0 + gravity must not drop floor-resting boxes"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_stability_drops_undersupported_box() {
1155        // "b" rests on "a" but overlaps only a thin sliver of its top face (well under
1156        // the 70% base-support threshold). Gravity alone keeps it (it does touch below);
1157        // stability drops it.
1158        let geometries = vec![
1159            Geometry3D::new("a", 40.0, 40.0, 20.0),
1160            Geometry3D::new("b", 40.0, 40.0, 20.0),
1161        ];
1162        let make = || {
1163            let mut r = SolveResult::new();
1164            r.placements.push(
1165                Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1166                    .with_rotation_index(0),
1167            );
1168            // shifted so only a 5x40 strip (≈12.5%) of b's base sits on a's top
1169            r.placements.push(
1170                Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1171                    .with_rotation_index(0),
1172            );
1173            r
1174        };
1175        let packer = Packer3D::default_config();
1176
1177        let mut grav = make();
1178        packer.enforce_support(
1179            &mut grav,
1180            &geometries,
1181            &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1182        );
1183        assert_eq!(
1184            grav.placements.len(),
1185            2,
1186            "gravity keeps a box that touches below"
1187        );
1188
1189        let mut stab = make();
1190        packer.enforce_support(
1191            &mut stab,
1192            &geometries,
1193            &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1194        );
1195        assert!(
1196            stab.placements.iter().all(|p| p.geometry_id == "a"),
1197            "stability drops the under-supported box"
1198        );
1199    }
1200
1201    #[test]
1202    fn test_layer_packing_orientation_optimization() {
1203        use crate::geometry::OrientationConstraint;
1204
1205        // A box 50x10x10 that won't fit in 45 width without rotation
1206        // But at orientation (1,0,2) it becomes 10x50x10, width=10, which fits
1207        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1208            .with_quantity(2)
1209            .with_orientation(OrientationConstraint::Any)];
1210
1211        // Narrow container: width=45, depth=80, height=80
1212        let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1213        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1214        let packer = Packer3D::new(config);
1215
1216        let result = packer.solve(&geometries, &boundary).unwrap();
1217
1218        // Both boxes should be placed via orientation change
1219        assert_eq!(
1220            result.placements.len(),
1221            2,
1222            "Both boxes should be placed by using rotation"
1223        );
1224        assert!(result.unplaced.is_empty());
1225
1226        // Verify orientation index is set for placements
1227        for p in &result.placements {
1228            assert!(
1229                p.rotation_index.is_some(),
1230                "Placement should have rotation_index set"
1231            );
1232        }
1233    }
1234
1235    #[test]
1236    fn test_layer_packing_selects_best_orientation() {
1237        use crate::geometry::OrientationConstraint;
1238
1239        // Box 30x20x10 in container 35x50x100
1240        // Original orientation (30x20x10): fits in row, leaves 5 spare width
1241        // Rotated (20x30x10): fits but uses more depth
1242        // Best: original orientation to minimize vertical space usage
1243        let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1244            .with_quantity(1)
1245            .with_orientation(OrientationConstraint::Any)];
1246
1247        let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1248        let packer = Packer3D::default_config();
1249
1250        let result = packer.solve(&geometries, &boundary).unwrap();
1251
1252        assert_eq!(result.placements.len(), 1);
1253        assert!(result.unplaced.is_empty());
1254    }
1255}