openbim_ifcx_geometry/
curves.rs

1//! Polylines from `usd::usdgeom::basiscurves`.
2//!
3//! In `ifcx_alpha` files the attribute is an object with `points` and,
4//! optionally, `curveVertexCounts`, which splits the points into several
5//! curves:
6//!
7//! ```json
8//! "usd::usdgeom::basiscurves": {
9//!     "points": [[0, 0, 0], [10, 0, 0], [0, 5, 0], [0, 5, 3]],
10//!     "curveVertexCounts": [2, 2]
11//! }
12//! ```
13//!
14//! None of the 85 curves in the upstream examples (`1a63082`) has a `type`,
15//! `basis`, or `wrap` field, and upstream's viewer draws every one as a
16//! straight polyline. This crate does the same: a missing `type` means
17//! linear. That differs from USD, whose schema default is `cubic`. A `type`
18//! other than `"linear"` yields [`CurveGeometry::Unsupported`] instead of a
19//! polyline that would misrepresent the curve. For linear curves `basis` is
20//! irrelevant and ignored; `wrap: "periodic"` closes each polyline, and
21//! `"nonperiodic"` or `"pinned"` leave it open.
22
23use serde_json::Value;
24
25use crate::error::DecodeError;
26use crate::json;
27use crate::math::Vec3;
28
29/// Attribute id that carries curves.
30pub const ATTRIBUTE: &str = "usd::usdgeom::basiscurves";
31
32/// One connected line strip in the node's local coordinates.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Polyline {
35    /// Vertices in order; at least two.
36    pub points: Vec<Vec3>,
37    /// Whether the last vertex connects back to the first.
38    pub closed: bool,
39}
40
41/// A curve type this crate does not turn into polylines.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct UnsupportedCurve {
44    /// The `type` field, for example `"cubic"`.
45    pub curve_type: String,
46    /// The `basis` field, if present, for example `"bspline"`.
47    pub basis: Option<String>,
48}
49
50/// The result of decoding a curves attribute that is well formed.
51#[derive(Debug, Clone, PartialEq)]
52pub enum CurveGeometry {
53    /// Linear curves, one polyline per curve.
54    Polylines(Vec<Polyline>),
55    /// Curves of a type that is not drawn yet. Skip them rather than draw
56    /// their control points as lines.
57    Unsupported(UnsupportedCurve),
58}
59
60impl CurveGeometry {
61    /// Decodes the value of a [`ATTRIBUTE`] (`usd::usdgeom::basiscurves`)
62    /// attribute.
63    ///
64    /// ```
65    /// use openbim_ifcx_geometry::curves::CurveGeometry;
66    ///
67    /// let value = serde_json::json!({"points": [[0, 0, 0], [10, 0, 0]]});
68    /// let CurveGeometry::Polylines(lines) = CurveGeometry::from_attribute(&value)? else {
69    ///     unreachable!()
70    /// };
71    /// assert_eq!(lines[0].points, [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]);
72    /// # Ok::<(), openbim_ifcx_geometry::DecodeError>(())
73    /// ```
74    pub fn from_attribute(value: &Value) -> Result<Self, DecodeError> {
75        let object = json::object(value, ATTRIBUTE)?;
76        let curve_type = json::optional_str(object, "type")?;
77        let basis = json::optional_str(object, "basis")?;
78        let wrap = json::optional_str(object, "wrap")?;
79        let points = json::points3(json::field(object, "points")?, "points")?;
80
81        if let Some(t) = curve_type.filter(|t| *t != "linear") {
82            return Ok(Self::Unsupported(UnsupportedCurve {
83                curve_type: t.to_owned(),
84                basis: basis.map(str::to_owned),
85            }));
86        }
87        let closed = match wrap {
88            None | Some("nonperiodic") | Some("pinned") => false,
89            Some("periodic") => true,
90            Some(_) => {
91                return Err(DecodeError::WrongShape {
92                    at: "wrap".to_owned(),
93                    expected: "\"nonperiodic\", \"periodic\", or \"pinned\"",
94                })
95            }
96        };
97
98        let counts = match object.get("curveVertexCounts") {
99            Some(v) => json::counts(v, "curveVertexCounts")?,
100            None if points.is_empty() => Vec::new(),
101            None => vec![points.len() as u64],
102        };
103        let counted = counts.iter().try_fold(0u64, |sum, &n| sum.checked_add(n));
104        if counted != Some(points.len() as u64) {
105            return Err(DecodeError::CountMismatch {
106                field: "curveVertexCounts",
107                counted: counted.unwrap_or(u64::MAX),
108                available: points.len(),
109            });
110        }
111
112        let mut polylines = Vec::with_capacity(counts.len());
113        let mut rest = points.as_slice();
114        for (curve, &n) in counts.iter().enumerate() {
115            if n < 2 {
116                return Err(DecodeError::CurveTooShort {
117                    curve,
118                    vertex_count: n,
119                });
120            }
121            // The sum check above guarantees `n` fits.
122            let (head, tail) = rest.split_at(n as usize);
123            rest = tail;
124            polylines.push(Polyline {
125                points: head.to_vec(),
126                closed,
127            });
128        }
129        Ok(Self::Polylines(polylines))
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use serde_json::json;
137
138    fn polylines(v: Value) -> Vec<Polyline> {
139        match CurveGeometry::from_attribute(&v).unwrap() {
140            CurveGeometry::Polylines(p) => p,
141            other => panic!("expected polylines, got {other:?}"),
142        }
143    }
144
145    #[test]
146    fn linear_curve_yields_its_vertices() {
147        let lines = polylines(json!({"points": [[0, 0, 0], [10, 0, 0], [10, 0.5, 3]]}));
148        assert_eq!(
149            lines,
150            vec![Polyline {
151                points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 0.5, 3.0]],
152                closed: false
153            }]
154        );
155    }
156
157    #[test]
158    fn explicit_linear_type_is_accepted() {
159        let lines = polylines(json!({
160            "type": "linear", "basis": "bezier", "points": [[0, 0, 0], [1, 0, 0]]
161        }));
162        assert_eq!(lines.len(), 1);
163    }
164
165    #[test]
166    fn curve_vertex_counts_split_curves() {
167        let lines = polylines(json!({
168            "points": [[0, 0, 0], [1, 0, 0], [2, 0, 0], [0, 5, 0], [0, 5, 3]],
169            "curveVertexCounts": [3, 2]
170        }));
171        assert_eq!(lines.len(), 2);
172        assert_eq!(lines[0].points.len(), 3);
173        assert_eq!(lines[1].points, vec![[0.0, 5.0, 0.0], [0.0, 5.0, 3.0]]);
174    }
175
176    #[test]
177    fn no_points_means_no_curves() {
178        assert!(polylines(json!({"points": []})).is_empty());
179    }
180
181    #[test]
182    fn periodic_wrap_closes() {
183        let lines = polylines(json!({
184            "wrap": "periodic", "points": [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
185        }));
186        assert!(lines[0].closed);
187    }
188
189    #[test]
190    fn non_linear_types_are_unsupported() {
191        let g = CurveGeometry::from_attribute(&json!({
192            "type": "cubic", "basis": "bspline",
193            "points": [[0, 0, 0], [1, 1, 0], [2, 0, 0], [3, 1, 0]]
194        }))
195        .unwrap();
196        assert_eq!(
197            g,
198            CurveGeometry::Unsupported(UnsupportedCurve {
199                curve_type: "cubic".into(),
200                basis: Some("bspline".into())
201            })
202        );
203    }
204
205    #[test]
206    fn malformed_curves_are_errors() {
207        let err = |v: Value| CurveGeometry::from_attribute(&v).unwrap_err();
208        assert_eq!(
209            err(json!({"curveVertexCounts": [2]})),
210            DecodeError::MissingField { field: "points" }
211        );
212        assert_eq!(
213            err(json!({"points": [[0, 0, 0], [1, 0, 0]], "curveVertexCounts": [3]})),
214            DecodeError::CountMismatch {
215                field: "curveVertexCounts",
216                counted: 3,
217                available: 2
218            }
219        );
220        assert_eq!(
221            err(json!({"points": [[0, 0, 0], [1, 0, 0], [2, 0, 0]], "curveVertexCounts": [2, 1]})),
222            DecodeError::CurveTooShort {
223                curve: 1,
224                vertex_count: 1
225            }
226        );
227        assert_eq!(
228            err(json!({"points": [[0, 0, 0]]})),
229            DecodeError::CurveTooShort {
230                curve: 0,
231                vertex_count: 1
232            }
233        );
234        assert_eq!(
235            err(json!({"points": [[0, 0, 0], [1, 0]]})),
236            DecodeError::WrongShape {
237                at: "points[1]".into(),
238                expected: "array of 3 numbers"
239            }
240        );
241        assert!(matches!(
242            err(json!({"type": 3, "points": []})),
243            DecodeError::WrongShape { .. }
244        ));
245        assert!(matches!(
246            err(json!({"wrap": "spiral", "points": [[0, 0, 0], [1, 0, 0]]})),
247            DecodeError::WrongShape { .. }
248        ));
249    }
250}