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