Skip to main content

u_nesting_d3/
packing_utils.rs

1//! Shared utilities for 3D bin packing solvers.
2//!
3//! This module consolidates common code across GA, SA, and BRKGA packing solvers,
4//! following the same deduplication pattern as d2's `placement_utils`.
5//!
6//! # Extracted Components
7//!
8//! - [`InstanceInfo`]: Maps expanded instances back to their source geometry
9//! - [`packing_fitness`]: Unified fitness formula for all solvers
10//! - [`build_unplaced_list`]: Identifies geometries not placed in the solution
11//! - [`layer_place_items`]: Core layer-based placement algorithm shared by all solvers
12
13use crate::boundary::Boundary3D;
14use crate::geometry::Geometry3D;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use u_nesting_core::geometry::{Boundary, Geometry};
18use u_nesting_core::solver::Config;
19use u_nesting_core::Placement;
20
21/// Instance information mapping expanded instances to source geometries.
22///
23/// When a geometry has quantity > 1, it expands into multiple instances.
24/// This struct tracks which geometry each instance belongs to and its
25/// ordinal within that geometry's quantity.
26#[derive(Debug, Clone)]
27pub struct InstanceInfo {
28    /// Index into the geometries array.
29    pub geometry_idx: usize,
30    /// Instance number within this geometry's quantity.
31    pub instance_num: usize,
32    /// Number of allowed orientations.
33    pub orientation_count: usize,
34}
35
36/// Builds the instance mapping from geometries.
37///
38/// Expands each geometry by its quantity, recording the geometry index,
39/// instance number, and orientation count for each expanded instance.
40pub fn build_instances(geometries: &[Geometry3D]) -> Vec<InstanceInfo> {
41    let mut instances = Vec::new();
42    for (geom_idx, geom) in geometries.iter().enumerate() {
43        let orient_count = geom.allowed_orientations().len();
44        for instance_num in 0..geom.quantity() {
45            instances.push(InstanceInfo {
46                geometry_idx: geom_idx,
47                instance_num,
48                orientation_count: orient_count,
49            });
50        }
51    }
52    instances
53}
54
55/// Represents a single item to be placed, with its resolved orientation.
56pub struct PlacementItem {
57    /// Index into the instances array.
58    pub instance_idx: usize,
59    /// Resolved orientation index for this item.
60    pub orientation_idx: usize,
61}
62
63/// Computes packing fitness using the unified formula.
64///
65/// The fitness combines placement ratio and volume utilization:
66/// `fitness = (placed / total) * 100 + utilization * 10`
67///
68/// This prioritizes placing more items (100x weight) while also
69/// rewarding tighter packing (10x weight).
70///
71/// # Arguments
72/// * `placed_count` - Number of items successfully placed
73/// * `total_count` - Total number of items to place
74/// * `utilization` - Volume utilization ratio (0.0 to 1.0)
75pub fn packing_fitness(placed_count: usize, total_count: usize, utilization: f64) -> f64 {
76    let placement_ratio = placed_count as f64 / total_count.max(1) as f64;
77    placement_ratio * 100.0 + utilization * 10.0
78}
79
80/// Builds a list of geometry IDs that were not placed.
81///
82/// Compares placed geometry IDs against the full input set to identify
83/// which geometries have no placements.
84pub fn build_unplaced_list(
85    placements: &[Placement<f64>],
86    geometries: &[Geometry3D],
87) -> Vec<String> {
88    let mut placed_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
89    for p in placements {
90        placed_ids.insert(p.geometry_id.clone());
91    }
92    let mut unplaced = Vec::new();
93    for geom in geometries {
94        if !placed_ids.contains(geom.id()) {
95            unplaced.push(geom.id().clone());
96        }
97    }
98    unplaced
99}
100
101/// Result of the layer-based placement algorithm.
102pub struct LayerPlacementResult {
103    /// Successfully placed items.
104    pub placements: Vec<Placement<f64>>,
105    /// Volume utilization ratio.
106    pub utilization: f64,
107    /// Number of items placed.
108    pub placed_count: usize,
109}
110
111/// Core layer-based placement algorithm shared by all 3D packing solvers.
112///
113/// Places items in a layer-by-layer, row-by-row fashion within the boundary.
114/// Items are placed left-to-right in rows, rows stack front-to-back in layers,
115/// and layers stack bottom-to-top.
116///
117/// # Algorithm
118///
119/// For each item in the given order:
120/// 1. Get oriented dimensions
121/// 2. Check mass constraint
122/// 3. Try to fit in current row (x direction)
123/// 4. If not, move to next row (y direction)
124/// 5. If row doesn't fit, move to next layer (z direction)
125/// 6. If layer doesn't fit, skip item
126///
127/// # Arguments
128///
129/// * `items` - Ordered list of items with resolved orientations
130/// * `instances` - Instance mapping (geometry index, instance number)
131/// * `geometries` - Source geometry definitions
132/// * `boundary` - Container dimensions and constraints
133/// * `config` - Margin and spacing configuration
134/// * `cancelled` - Cancellation flag for early termination
135pub fn layer_place_items(
136    items: &[PlacementItem],
137    instances: &[InstanceInfo],
138    geometries: &[Geometry3D],
139    boundary: &Boundary3D,
140    config: &Config,
141    cancelled: &Arc<AtomicBool>,
142) -> LayerPlacementResult {
143    let mut placements = Vec::new();
144
145    let margin = config.margin;
146    let spacing = config.spacing;
147
148    let bound_max_x = boundary.width() - margin;
149    let bound_max_y = boundary.depth() - margin;
150    let bound_max_z = boundary.height() - margin;
151
152    let mut current_x = margin;
153    let mut current_y = margin;
154    let mut current_z = margin;
155    let mut row_depth = 0.0_f64;
156    let mut layer_height = 0.0_f64;
157
158    let mut total_placed_volume = 0.0;
159    let mut total_placed_mass = 0.0;
160    let mut placed_count = 0;
161
162    for item in items {
163        if cancelled.load(Ordering::Relaxed) {
164            break;
165        }
166
167        if item.instance_idx >= instances.len() {
168            continue;
169        }
170
171        let info = &instances[item.instance_idx];
172        let geom = &geometries[info.geometry_idx];
173
174        let orientation_idx = item.orientation_idx % info.orientation_count.max(1);
175
176        // Get dimensions for this orientation
177        let dims = geom.dimensions_for_orientation(orientation_idx);
178        let g_width = dims.x;
179        let g_depth = dims.y;
180        let g_height = dims.z;
181
182        // Check mass constraint
183        if let (Some(max_mass), Some(item_mass)) = (boundary.max_mass(), geom.mass()) {
184            if total_placed_mass + item_mass > max_mass {
185                continue;
186            }
187        }
188
189        // Try to fit in current row
190        if current_x + g_width > bound_max_x {
191            current_x = margin;
192            current_y += row_depth + spacing;
193            row_depth = 0.0;
194        }
195
196        // Check if fits in current layer (y direction)
197        if current_y + g_depth > bound_max_y {
198            current_x = margin;
199            current_y = margin;
200            current_z += layer_height + spacing;
201            row_depth = 0.0;
202            layer_height = 0.0;
203        }
204
205        // Check if fits in container height
206        if current_z + g_height > bound_max_z {
207            continue;
208        }
209
210        // Place the item
211        let placement = Placement::new_3d(
212            geom.id().clone(),
213            info.instance_num,
214            current_x,
215            current_y,
216            current_z,
217            0.0,
218            0.0,
219            0.0,
220        );
221
222        placements.push(placement);
223        total_placed_volume += geom.measure();
224        if let Some(mass) = geom.mass() {
225            total_placed_mass += mass;
226        }
227        placed_count += 1;
228
229        // Update position for next item
230        current_x += g_width + spacing;
231        row_depth = row_depth.max(g_depth);
232        layer_height = layer_height.max(g_height);
233    }
234
235    let utilization = total_placed_volume / boundary.measure();
236    LayerPlacementResult {
237        placements,
238        utilization,
239        placed_count,
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_build_instances_single() {
249        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(3)];
250        let instances = build_instances(&geometries);
251        assert_eq!(instances.len(), 3);
252        assert_eq!(instances[0].geometry_idx, 0);
253        assert_eq!(instances[0].instance_num, 0);
254        assert_eq!(instances[2].instance_num, 2);
255    }
256
257    #[test]
258    fn test_build_instances_multiple() {
259        let geometries = vec![
260            Geometry3D::new("A", 10.0, 10.0, 10.0).with_quantity(2),
261            Geometry3D::new("B", 20.0, 20.0, 20.0).with_quantity(3),
262        ];
263        let instances = build_instances(&geometries);
264        assert_eq!(instances.len(), 5);
265        assert_eq!(instances[0].geometry_idx, 0);
266        assert_eq!(instances[1].geometry_idx, 0);
267        assert_eq!(instances[2].geometry_idx, 1);
268        assert_eq!(instances[4].geometry_idx, 1);
269    }
270
271    #[test]
272    fn test_packing_fitness_all_placed() {
273        let fitness = packing_fitness(10, 10, 0.8);
274        // 1.0 * 100 + 0.8 * 10 = 108.0
275        assert!((fitness - 108.0).abs() < 1e-10);
276    }
277
278    #[test]
279    fn test_packing_fitness_partial() {
280        let fitness = packing_fitness(5, 10, 0.5);
281        // 0.5 * 100 + 0.5 * 10 = 55.0
282        assert!((fitness - 55.0).abs() < 1e-10);
283    }
284
285    #[test]
286    fn test_packing_fitness_none_placed() {
287        let fitness = packing_fitness(0, 10, 0.0);
288        assert!((fitness - 0.0).abs() < 1e-10);
289    }
290
291    #[test]
292    fn test_packing_fitness_zero_total() {
293        let fitness = packing_fitness(0, 0, 0.0);
294        assert!((fitness - 0.0).abs() < 1e-10);
295    }
296
297    #[test]
298    fn test_build_unplaced_list_all_placed() {
299        let geometries = vec![
300            Geometry3D::new("A", 10.0, 10.0, 10.0),
301            Geometry3D::new("B", 20.0, 20.0, 20.0),
302        ];
303        let placements = vec![
304            Placement::new_3d("A".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
305            Placement::new_3d("B".to_string(), 0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0),
306        ];
307        let unplaced = build_unplaced_list(&placements, &geometries);
308        assert!(unplaced.is_empty());
309    }
310
311    #[test]
312    fn test_build_unplaced_list_partial() {
313        let geometries = vec![
314            Geometry3D::new("A", 10.0, 10.0, 10.0),
315            Geometry3D::new("B", 20.0, 20.0, 20.0),
316        ];
317        let placements = vec![Placement::new_3d(
318            "A".to_string(),
319            0,
320            0.0,
321            0.0,
322            0.0,
323            0.0,
324            0.0,
325            0.0,
326        )];
327        let unplaced = build_unplaced_list(&placements, &geometries);
328        assert_eq!(unplaced, vec!["B"]);
329    }
330
331    #[test]
332    fn test_layer_place_items_basic() {
333        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2)];
334        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
335        let config = Config::default();
336        let instances = build_instances(&geometries);
337        let cancelled = Arc::new(AtomicBool::new(false));
338
339        let items: Vec<PlacementItem> = (0..instances.len())
340            .map(|i| PlacementItem {
341                instance_idx: i,
342                orientation_idx: 0,
343            })
344            .collect();
345
346        let result = layer_place_items(
347            &items,
348            &instances,
349            &geometries,
350            &boundary,
351            &config,
352            &cancelled,
353        );
354
355        assert_eq!(result.placed_count, 2);
356        assert_eq!(result.placements.len(), 2);
357        assert!(result.utilization > 0.0);
358    }
359
360    #[test]
361    fn test_layer_place_items_cancellation() {
362        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(5)];
363        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
364        let config = Config::default();
365        let instances = build_instances(&geometries);
366        let cancelled = Arc::new(AtomicBool::new(true)); // Already cancelled
367
368        let items: Vec<PlacementItem> = (0..instances.len())
369            .map(|i| PlacementItem {
370                instance_idx: i,
371                orientation_idx: 0,
372            })
373            .collect();
374
375        let result = layer_place_items(
376            &items,
377            &instances,
378            &geometries,
379            &boundary,
380            &config,
381            &cancelled,
382        );
383
384        assert_eq!(result.placed_count, 0);
385    }
386
387    #[test]
388    fn test_layer_place_items_mass_constraint() {
389        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
390            .with_quantity(10)
391            .with_mass(100.0)];
392        let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_max_mass(250.0);
393        let config = Config::default();
394        let instances = build_instances(&geometries);
395        let cancelled = Arc::new(AtomicBool::new(false));
396
397        let items: Vec<PlacementItem> = (0..instances.len())
398            .map(|i| PlacementItem {
399                instance_idx: i,
400                orientation_idx: 0,
401            })
402            .collect();
403
404        let result = layer_place_items(
405            &items,
406            &instances,
407            &geometries,
408            &boundary,
409            &config,
410            &cancelled,
411        );
412
413        // Max 2 boxes at 100kg each within 250kg limit
414        assert!(result.placed_count <= 2);
415    }
416}