Skip to main content

u_nesting_d3/
geometry.rs

1//! 3D geometry types.
2
3use u_nesting_core::geom::nalgebra_types::NaVector3 as Vector3;
4use u_nesting_core::geometry::{Geometry, GeometryId, RotationConstraint};
5use u_nesting_core::{Error, Result};
6
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10/// Orientation constraint for 3D placement.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
13pub enum OrientationConstraint {
14    /// Any orientation allowed (6 axis-aligned rotations for boxes).
15    #[default]
16    Any,
17    /// Only upright orientations (2 rotations: original and 90° around Z).
18    Upright,
19    /// Fixed orientation (no rotation allowed).
20    Fixed,
21}
22
23/// A 3D box geometry that can be packed.
24#[derive(Debug, Clone)]
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26pub struct Geometry3D {
27    /// Unique identifier.
28    id: GeometryId,
29
30    /// Dimensions (width, depth, height).
31    dimensions: Vector3<f64>,
32
33    /// Number of copies to place.
34    quantity: usize,
35
36    /// Mass of the geometry (for weight constraints).
37    mass: Option<f64>,
38
39    /// Orientation constraint (for 3D-specific orientations).
40    orientation: OrientationConstraint,
41
42    /// Rotation constraint (for Geometry trait).
43    rotation_constraint: RotationConstraint<f64>,
44
45    /// Whether this item can have other items stacked on top.
46    stackable: bool,
47
48    /// Maximum weight that can be placed on top of this item.
49    max_stack_weight: Option<f64>,
50}
51
52impl Geometry3D {
53    /// Creates a new 3D box geometry with the given ID and dimensions.
54    pub fn new(id: impl Into<GeometryId>, width: f64, depth: f64, height: f64) -> Self {
55        Self {
56            id: id.into(),
57            dimensions: Vector3::new(width, depth, height),
58            quantity: 1,
59            mass: None,
60            orientation: OrientationConstraint::default(),
61            rotation_constraint: RotationConstraint::None,
62            stackable: true,
63            max_stack_weight: None,
64        }
65    }
66
67    /// Alias for creating a box shape.
68    pub fn box_shape(id: impl Into<GeometryId>, width: f64, depth: f64, height: f64) -> Self {
69        Self::new(id, width, depth, height)
70    }
71
72    /// Sets the quantity to place.
73    pub fn with_quantity(mut self, n: usize) -> Self {
74        self.quantity = n;
75        self
76    }
77
78    /// Sets the mass.
79    pub fn with_mass(mut self, mass: f64) -> Self {
80        self.mass = Some(mass);
81        self
82    }
83
84    /// Sets the orientation constraint.
85    pub fn with_orientation(mut self, constraint: OrientationConstraint) -> Self {
86        self.orientation = constraint;
87        self
88    }
89
90    /// Sets whether items can be stacked on top.
91    pub fn with_stackable(mut self, stackable: bool) -> Self {
92        self.stackable = stackable;
93        self
94    }
95
96    /// Sets the maximum weight that can be stacked on top.
97    pub fn with_max_stack_weight(mut self, weight: f64) -> Self {
98        self.max_stack_weight = Some(weight);
99        self
100    }
101
102    /// Returns the dimensions (width, depth, height).
103    pub fn dimensions(&self) -> &Vector3<f64> {
104        &self.dimensions
105    }
106
107    /// Returns the width.
108    pub fn width(&self) -> f64 {
109        self.dimensions.x
110    }
111
112    /// Returns the depth.
113    pub fn depth(&self) -> f64 {
114        self.dimensions.y
115    }
116
117    /// Returns the height.
118    pub fn height(&self) -> f64 {
119        self.dimensions.z
120    }
121
122    /// Returns the mass.
123    pub fn mass(&self) -> Option<f64> {
124        self.mass
125    }
126
127    /// Returns the orientation constraint.
128    pub fn orientation_constraint(&self) -> OrientationConstraint {
129        self.orientation
130    }
131
132    /// Returns whether items can be stacked on top.
133    pub fn is_stackable(&self) -> bool {
134        self.stackable
135    }
136
137    /// Returns the allowed orientations based on the constraint.
138    /// Each orientation is (width_axis, depth_axis, height_axis).
139    pub fn allowed_orientations(&self) -> Vec<(usize, usize, usize)> {
140        match self.orientation {
141            OrientationConstraint::Fixed => vec![(0, 1, 2)],
142            OrientationConstraint::Upright => vec![(0, 1, 2), (1, 0, 2)],
143            OrientationConstraint::Any => vec![
144                (0, 1, 2), // Original
145                (0, 2, 1), // Rotated 90° around X
146                (1, 0, 2), // Rotated 90° around Z
147                (1, 2, 0), // Rotated 90° around X then Z
148                (2, 0, 1), // Rotated 90° around Y
149                (2, 1, 0), // Rotated 90° around Y then X
150            ],
151        }
152    }
153
154    /// Returns the orientation label for a given orientation index, as an
155    /// axis-permutation string (e.g. `"xyz"`, `"xzy"`).
156    ///
157    /// The permutation `(x_idx, y_idx, z_idx)` from [`allowed_orientations`]
158    /// is mapped to the axis letters `x`/`y`/`z`. Out-of-range indices fall
159    /// back to the identity orientation `"xyz"`.
160    ///
161    /// [`allowed_orientations`]: Self::allowed_orientations
162    pub fn orientation_label(&self, orientation: usize) -> String {
163        const AXES: [char; 3] = ['x', 'y', 'z'];
164        let orientations = self.allowed_orientations();
165        let (a, b, c) = orientations.get(orientation).copied().unwrap_or((0, 1, 2));
166        [AXES[a], AXES[b], AXES[c]].iter().collect()
167    }
168
169    /// Returns dimensions for a given orientation index.
170    pub fn dimensions_for_orientation(&self, orientation: usize) -> Vector3<f64> {
171        let orientations = self.allowed_orientations();
172        if orientation >= orientations.len() {
173            return self.dimensions;
174        }
175
176        let (x_idx, y_idx, z_idx) = orientations[orientation];
177        Vector3::new(
178            self.dimensions[x_idx],
179            self.dimensions[y_idx],
180            self.dimensions[z_idx],
181        )
182    }
183}
184
185impl Geometry for Geometry3D {
186    type Scalar = f64;
187
188    fn id(&self) -> &GeometryId {
189        &self.id
190    }
191
192    fn quantity(&self) -> usize {
193        self.quantity
194    }
195
196    fn measure(&self) -> f64 {
197        self.dimensions.x * self.dimensions.y * self.dimensions.z
198    }
199
200    fn aabb_vec(&self) -> (Vec<f64>, Vec<f64>) {
201        (
202            vec![0.0, 0.0, 0.0],
203            vec![self.dimensions.x, self.dimensions.y, self.dimensions.z],
204        )
205    }
206
207    fn centroid(&self) -> Vec<f64> {
208        vec![
209            self.dimensions.x / 2.0,
210            self.dimensions.y / 2.0,
211            self.dimensions.z / 2.0,
212        ]
213    }
214
215    fn rotation_constraint(&self) -> &RotationConstraint<f64> {
216        &self.rotation_constraint
217    }
218
219    fn validate(&self) -> Result<()> {
220        if self.dimensions.x <= 0.0 || self.dimensions.y <= 0.0 || self.dimensions.z <= 0.0 {
221            return Err(Error::InvalidGeometry(format!(
222                "All dimensions for '{}' must be positive",
223                self.id
224            )));
225        }
226
227        if self.quantity == 0 {
228            return Err(Error::InvalidGeometry(format!(
229                "Quantity for '{}' must be at least 1",
230                self.id
231            )));
232        }
233
234        if let Some(mass) = self.mass {
235            if mass < 0.0 {
236                return Err(Error::InvalidGeometry(format!(
237                    "Mass for '{}' cannot be negative",
238                    self.id
239                )));
240            }
241        }
242
243        Ok(())
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use approx::assert_relative_eq;
251
252    #[test]
253    fn test_box_volume() {
254        let box3d = Geometry3D::new("B1", 10.0, 20.0, 30.0);
255        assert_relative_eq!(box3d.measure(), 6000.0, epsilon = 0.001);
256    }
257
258    #[test]
259    fn test_orientations() {
260        let box3d = Geometry3D::new("B1", 10.0, 20.0, 30.0);
261
262        // Any orientation should have 6 options
263        assert_eq!(box3d.allowed_orientations().len(), 6);
264
265        // Upright should have 2 options
266        let upright = box3d
267            .clone()
268            .with_orientation(OrientationConstraint::Upright);
269        assert_eq!(upright.allowed_orientations().len(), 2);
270
271        // Fixed should have 1 option
272        let fixed = box3d.clone().with_orientation(OrientationConstraint::Fixed);
273        assert_eq!(fixed.allowed_orientations().len(), 1);
274    }
275
276    #[test]
277    fn test_orientation_label() {
278        // Default constraint is Any → 6 axis permutations map to labels.
279        let geom = Geometry3D::new("B1", 10.0, 20.0, 30.0);
280        assert_eq!(geom.orientation_label(0), "xyz");
281        assert_eq!(geom.orientation_label(1), "xzy");
282        assert_eq!(geom.orientation_label(2), "yxz");
283        assert_eq!(geom.orientation_label(3), "yzx");
284        assert_eq!(geom.orientation_label(4), "zxy");
285        assert_eq!(geom.orientation_label(5), "zyx");
286        // Out-of-range index falls back to identity orientation.
287        assert_eq!(geom.orientation_label(99), "xyz");
288
289        // Fixed constraint only ever yields the identity orientation.
290        let fixed = geom.clone().with_orientation(OrientationConstraint::Fixed);
291        assert_eq!(fixed.orientation_label(0), "xyz");
292    }
293
294    #[test]
295    fn test_aabb() {
296        use u_nesting_core::geometry::Geometry;
297        let box3d = Geometry3D::new("B1", 10.0, 20.0, 30.0);
298        let (min, max) = box3d.aabb_vec();
299
300        assert_eq!(min, vec![0.0, 0.0, 0.0]);
301        assert_eq!(max, vec![10.0, 20.0, 30.0]);
302    }
303
304    #[test]
305    fn test_validation() {
306        let valid = Geometry3D::new("B1", 10.0, 20.0, 30.0);
307        assert!(valid.validate().is_ok());
308
309        let invalid = Geometry3D::new("B2", -10.0, 20.0, 30.0);
310        assert!(invalid.validate().is_err());
311
312        let zero_qty = Geometry3D::new("B3", 10.0, 20.0, 30.0).with_quantity(0);
313        assert!(zero_qty.validate().is_err());
314    }
315}