Skip to main content

openworldformat/
shape.rs

1//! Parametric shape definitions — type-safe, never loses dimension info.
2//!
3//! Unlike the current `PrimitiveShape` + `HashMap<String, f32>` approach,
4//! dimensions are encoded directly in each variant.  This survives
5//! serialization roundtrips (unlike glTF export which flattens to triangles).
6
7use serde::{Deserialize, Serialize};
8
9/// A parametric primitive shape with inline dimensions.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12pub enum Shape {
13    Cuboid {
14        #[serde(default = "default_one")]
15        x: f32,
16        #[serde(default = "default_one")]
17        y: f32,
18        #[serde(default = "default_one")]
19        z: f32,
20    },
21    Sphere {
22        #[serde(default = "default_half")]
23        radius: f32,
24    },
25    Cylinder {
26        #[serde(default = "default_half")]
27        radius: f32,
28        #[serde(default = "default_one")]
29        height: f32,
30    },
31    Cone {
32        #[serde(default = "default_half")]
33        radius: f32,
34        #[serde(default = "default_one")]
35        height: f32,
36    },
37    Capsule {
38        #[serde(default = "default_half")]
39        radius: f32,
40        #[serde(default = "default_half")]
41        half_length: f32,
42    },
43    Torus {
44        #[serde(default = "default_one")]
45        major_radius: f32,
46        #[serde(default = "default_quarter")]
47        minor_radius: f32,
48    },
49    Plane {
50        #[serde(default = "default_ten")]
51        x: f32,
52        #[serde(default = "default_ten")]
53        z: f32,
54    },
55    Pyramid {
56        #[serde(default = "default_one")]
57        base_x: f32,
58        #[serde(default = "default_one")]
59        base_z: f32,
60        #[serde(default = "default_one")]
61        height: f32,
62    },
63    Tetrahedron {
64        #[serde(default = "default_one")]
65        radius: f32,
66    },
67    Icosahedron {
68        #[serde(default = "default_one")]
69        radius: f32,
70    },
71    Wedge {
72        #[serde(default = "default_one")]
73        x: f32,
74        #[serde(default = "default_one")]
75        y: f32,
76        #[serde(default = "default_one")]
77        z: f32,
78    },
79    /// A right prism on a regular polygon, `sides` long (spec/world.md "The geometry of the shapes"). Origin at the centroid, axis along local +Y, flat caps at y = ±height/2. `radius` is the circumradius: cap vertex i sits at (radius·cos θ, ±height/2, −radius·sin θ) with θ = 2π·i/sides, so the first vertex is on +X and the ring runs counter-clockwise seen from above. Faces wind outward; no segment parameter beyond `sides`.
80    Prism {
81        /// How many sides, 3 to 8 (6 is a hexagonal prism, 5 a pentagonal one).
82        #[serde(default = "default_six")]
83        #[cfg_attr(feature = "schema", schemars(range(min = 3, max = 8)))]
84        sides: u8,
85        /// The circumradius: centre to any cap vertex.
86        #[serde(default = "default_half")]
87        radius: f32,
88        /// Cap to cap along +Y.
89        #[serde(default = "default_one")]
90        height: f32,
91    },
92}
93
94fn default_one() -> f32 {
95    1.0
96}
97fn default_six() -> u8 {
98    6
99}
100fn default_half() -> f32 {
101    0.5
102}
103fn default_quarter() -> f32 {
104    0.25
105}
106fn default_ten() -> f32 {
107    10.0
108}
109
110impl Shape {
111    /// Rough triangle count estimate for budget tracking.
112    pub fn estimate_triangles(&self) -> usize {
113        match self {
114            Shape::Cuboid { .. } => 12,
115            Shape::Sphere { .. } => 760,   // 20x20 UV sphere
116            Shape::Cylinder { .. } => 124, // 32 segments
117            Shape::Cone { .. } => 64,      // 32 segments
118            Shape::Capsule { .. } => 1520, // 2 hemispheres + cylinder
119            Shape::Torus { .. } => 2048,   // 32x32 segments
120            Shape::Plane { .. } => 2,
121            Shape::Pyramid { .. } => 6, // 4 triangular sides + 2 for base quad
122            Shape::Tetrahedron { .. } => 4, // 4 triangular faces
123            Shape::Icosahedron { .. } => 20, // 20 triangular faces
124            Shape::Wedge { .. } => 8,   // 2 end caps + 3 sides (as triangles)
125            // Each side is a quad (2), each cap a fan of sides − 2.
126            Shape::Prism { sides, .. } => 4 * usize::from(*sides).max(3) - 4,
127        }
128    }
129
130    /// Axis-aligned bounding box half-extents in local space.
131    pub fn local_aabb_half(&self) -> [f32; 3] {
132        match self {
133            Shape::Cuboid { x, y, z } => [x / 2.0, y / 2.0, z / 2.0],
134            Shape::Sphere { radius } => [*radius, *radius, *radius],
135            Shape::Cylinder { radius, height } => [*radius, height / 2.0, *radius],
136            Shape::Cone { radius, height } => [*radius, height / 2.0, *radius],
137            Shape::Capsule {
138                radius,
139                half_length,
140            } => [*radius, half_length + radius, *radius],
141            Shape::Torus {
142                major_radius,
143                minor_radius,
144            } => [
145                major_radius + minor_radius,
146                *minor_radius,
147                major_radius + minor_radius,
148            ],
149            Shape::Plane { x, z } => [x / 2.0, 0.0, z / 2.0],
150            Shape::Pyramid {
151                base_x,
152                base_z,
153                height,
154            } => [base_x / 2.0, height / 2.0, base_z / 2.0],
155            Shape::Tetrahedron { radius } => [*radius, *radius, *radius],
156            Shape::Icosahedron { radius } => [*radius, *radius, *radius],
157            Shape::Wedge { x, y, z } => [x / 2.0, y / 2.0, z / 2.0],
158            // The cylinder it is inscribed in: exact for the square (a
159            // vertex on each axis), conservative for the others.
160            Shape::Prism { radius, height, .. } => [*radius, height / 2.0, *radius],
161        }
162    }
163
164    /// Returns the shape kind as a string (for display/logging).
165    pub fn kind(&self) -> &'static str {
166        match self {
167            Shape::Cuboid { .. } => "cuboid",
168            Shape::Sphere { .. } => "sphere",
169            Shape::Cylinder { .. } => "cylinder",
170            Shape::Cone { .. } => "cone",
171            Shape::Capsule { .. } => "capsule",
172            Shape::Torus { .. } => "torus",
173            Shape::Plane { .. } => "plane",
174            Shape::Pyramid { .. } => "pyramid",
175            Shape::Tetrahedron { .. } => "tetrahedron",
176            Shape::Icosahedron { .. } => "icosahedron",
177            Shape::Wedge { .. } => "wedge",
178            Shape::Prism { .. } => "prism",
179        }
180    }
181}
182
183/// Maps to the legacy `PrimitiveShape` enum for compatibility with gen commands.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186pub enum PrimitiveShapeKind {
187    Cuboid,
188    Sphere,
189    Cylinder,
190    Cone,
191    Capsule,
192    Torus,
193    Plane,
194    Pyramid,
195    Tetrahedron,
196    Icosahedron,
197    Wedge,
198    Prism,
199}
200
201impl Shape {
202    /// Extract the shape kind without dimensions.
203    pub fn primitive_kind(&self) -> PrimitiveShapeKind {
204        match self {
205            Shape::Cuboid { .. } => PrimitiveShapeKind::Cuboid,
206            Shape::Sphere { .. } => PrimitiveShapeKind::Sphere,
207            Shape::Cylinder { .. } => PrimitiveShapeKind::Cylinder,
208            Shape::Cone { .. } => PrimitiveShapeKind::Cone,
209            Shape::Capsule { .. } => PrimitiveShapeKind::Capsule,
210            Shape::Torus { .. } => PrimitiveShapeKind::Torus,
211            Shape::Plane { .. } => PrimitiveShapeKind::Plane,
212            Shape::Pyramid { .. } => PrimitiveShapeKind::Pyramid,
213            Shape::Tetrahedron { .. } => PrimitiveShapeKind::Tetrahedron,
214            Shape::Icosahedron { .. } => PrimitiveShapeKind::Icosahedron,
215            Shape::Wedge { .. } => PrimitiveShapeKind::Wedge,
216            Shape::Prism { .. } => PrimitiveShapeKind::Prism,
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn shape_roundtrip_json() {
227        let shapes = vec![
228            Shape::Cuboid {
229                x: 4.0,
230                y: 3.0,
231                z: 5.0,
232            },
233            Shape::Sphere { radius: 2.0 },
234            Shape::Torus {
235                major_radius: 3.0,
236                minor_radius: 0.5,
237            },
238            Shape::Plane { x: 20.0, z: 20.0 },
239            Shape::Pyramid {
240                base_x: 4.0,
241                base_z: 4.0,
242                height: 3.0,
243            },
244            Shape::Tetrahedron { radius: 1.5 },
245            Shape::Icosahedron { radius: 2.0 },
246            Shape::Wedge {
247                x: 2.0,
248                y: 1.0,
249                z: 3.0,
250            },
251            Shape::Prism {
252                sides: 6,
253                radius: 0.6,
254                height: 1.5,
255            },
256        ];
257
258        for shape in &shapes {
259            let json = serde_json::to_string(shape).unwrap();
260            let back: Shape = serde_json::from_str(&json).unwrap();
261            assert_eq!(*shape, back, "roundtrip failed for {}", shape.kind());
262        }
263    }
264
265    /// The prism as the spec states it: `sides` is the only segment
266    /// parameter, the defaults are a hexagonal prism of radius 0.5 and
267    /// height 1, the box is the inscribing cylinder's, and the budget
268    /// counts two triangles per side plus two fanned caps.
269    #[test]
270    fn prism_defaults_box_and_budget() {
271        let prism: Shape = serde_json::from_str(r#"{"Prism": {}}"#).unwrap();
272        assert_eq!(
273            prism,
274            Shape::Prism {
275                sides: 6,
276                radius: 0.5,
277                height: 1.0
278            }
279        );
280        assert_eq!(prism.kind(), "prism");
281        assert_eq!(prism.primitive_kind(), PrimitiveShapeKind::Prism);
282        assert_eq!(prism.local_aabb_half(), [0.5, 0.5, 0.5]);
283        assert_eq!(prism.estimate_triangles(), 20); // 6 sides × 2 + 2 caps × 4
284        let triangle: Shape =
285            serde_json::from_str(r#"{"Prism": {"sides": 3, "radius": 1.0, "height": 2.0}}"#)
286                .unwrap();
287        assert_eq!(triangle.estimate_triangles(), 8); // 3 × 2 + 2 × 1
288        assert_eq!(triangle.local_aabb_half(), [1.0, 1.0, 1.0]);
289        // The wire form is the externally tagged variant like every shape.
290        assert_eq!(
291            serde_json::to_string(&triangle).unwrap(),
292            r#"{"Prism":{"sides":3,"radius":1.0,"height":2.0}}"#
293        );
294    }
295
296    #[test]
297    fn cuboid_aabb() {
298        let s = Shape::Cuboid {
299            x: 4.0,
300            y: 6.0,
301            z: 2.0,
302        };
303        assert_eq!(s.local_aabb_half(), [2.0, 3.0, 1.0]);
304    }
305
306    #[test]
307    fn triangle_estimates_are_nonzero() {
308        let shapes = [
309            Shape::Cuboid {
310                x: 1.0,
311                y: 1.0,
312                z: 1.0,
313            },
314            Shape::Sphere { radius: 1.0 },
315            Shape::Cylinder {
316                radius: 1.0,
317                height: 1.0,
318            },
319            Shape::Cone {
320                radius: 1.0,
321                height: 1.0,
322            },
323            Shape::Capsule {
324                radius: 1.0,
325                half_length: 1.0,
326            },
327            Shape::Torus {
328                major_radius: 1.0,
329                minor_radius: 0.25,
330            },
331            Shape::Plane { x: 1.0, z: 1.0 },
332            Shape::Pyramid {
333                base_x: 4.0,
334                base_z: 4.0,
335                height: 3.0,
336            },
337            Shape::Tetrahedron { radius: 1.0 },
338            Shape::Icosahedron { radius: 1.0 },
339            Shape::Wedge {
340                x: 1.0,
341                y: 1.0,
342                z: 1.0,
343            },
344            Shape::Prism {
345                sides: 8,
346                radius: 1.0,
347                height: 1.0,
348            },
349        ];
350        for s in &shapes {
351            assert!(s.estimate_triangles() > 0, "{} has 0 triangles", s.kind());
352        }
353    }
354}