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