u_nesting_d3/
geometry.rs

1//! 3D geometry types.
2
3use nalgebra::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 dimensions for a given orientation index.
155    pub fn dimensions_for_orientation(&self, orientation: usize) -> Vector3<f64> {
156        let orientations = self.allowed_orientations();
157        if orientation >= orientations.len() {
158            return self.dimensions;
159        }
160
161        let (x_idx, y_idx, z_idx) = orientations[orientation];
162        Vector3::new(
163            self.dimensions[x_idx],
164            self.dimensions[y_idx],
165            self.dimensions[z_idx],
166        )
167    }
168}
169
170impl Geometry for Geometry3D {
171    type Scalar = f64;
172
173    fn id(&self) -> &GeometryId {
174        &self.id
175    }
176
177    fn quantity(&self) -> usize {
178        self.quantity
179    }
180
181    fn measure(&self) -> f64 {
182        self.dimensions.x * self.dimensions.y * self.dimensions.z
183    }
184
185    fn aabb_vec(&self) -> (Vec<f64>, Vec<f64>) {
186        (
187            vec![0.0, 0.0, 0.0],
188            vec![self.dimensions.x, self.dimensions.y, self.dimensions.z],
189        )
190    }
191
192    fn centroid(&self) -> Vec<f64> {
193        vec![
194            self.dimensions.x / 2.0,
195            self.dimensions.y / 2.0,
196            self.dimensions.z / 2.0,
197        ]
198    }
199
200    fn rotation_constraint(&self) -> &RotationConstraint<f64> {
201        &self.rotation_constraint
202    }
203
204    fn validate(&self) -> Result<()> {
205        if self.dimensions.x <= 0.0 || self.dimensions.y <= 0.0 || self.dimensions.z <= 0.0 {
206            return Err(Error::InvalidGeometry(format!(
207                "All dimensions for '{}' must be positive",
208                self.id
209            )));
210        }
211
212        if self.quantity == 0 {
213            return Err(Error::InvalidGeometry(format!(
214                "Quantity for '{}' must be at least 1",
215                self.id
216            )));
217        }
218
219        if let Some(mass) = self.mass {
220            if mass < 0.0 {
221                return Err(Error::InvalidGeometry(format!(
222                    "Mass for '{}' cannot be negative",
223                    self.id
224                )));
225            }
226        }
227
228        Ok(())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use approx::assert_relative_eq;
236
237    #[test]
238    fn test_box_volume() {
239        let box3d = Geometry3D::new("B1", 10.0, 20.0, 30.0);
240        assert_relative_eq!(box3d.measure(), 6000.0, epsilon = 0.001);
241    }
242
243    #[test]
244    fn test_orientations() {
245        let box3d = Geometry3D::new("B1", 10.0, 20.0, 30.0);
246
247        // Any orientation should have 6 options
248        assert_eq!(box3d.allowed_orientations().len(), 6);
249
250        // Upright should have 2 options
251        let upright = box3d
252            .clone()
253            .with_orientation(OrientationConstraint::Upright);
254        assert_eq!(upright.allowed_orientations().len(), 2);
255
256        // Fixed should have 1 option
257        let fixed = box3d.clone().with_orientation(OrientationConstraint::Fixed);
258        assert_eq!(fixed.allowed_orientations().len(), 1);
259    }
260
261    #[test]
262    fn test_aabb() {
263        use u_nesting_core::geometry::Geometry;
264        let box3d = Geometry3D::new("B1", 10.0, 20.0, 30.0);
265        let (min, max) = box3d.aabb_vec();
266
267        assert_eq!(min, vec![0.0, 0.0, 0.0]);
268        assert_eq!(max, vec![10.0, 20.0, 30.0]);
269    }
270
271    #[test]
272    fn test_validation() {
273        let valid = Geometry3D::new("B1", 10.0, 20.0, 30.0);
274        assert!(valid.validate().is_ok());
275
276        let invalid = Geometry3D::new("B2", -10.0, 20.0, 30.0);
277        assert!(invalid.validate().is_err());
278
279        let zero_qty = Geometry3D::new("B3", 10.0, 20.0, 30.0).with_quantity(0);
280        assert!(zero_qty.validate().is_err());
281    }
282}