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