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