openbim_ifcx_geometry/
transform.rs

1//! Local and world transforms from `usd::xformop`.
2//!
3//! # Matrix layout
4//!
5//! In `ifcx_alpha` files the attribute is an object with one field,
6//! `transform`, holding four rows of four numbers:
7//!
8//! ```json
9//! "usd::xformop": {"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [5, 0, 2, 1]]}
10//! ```
11//!
12//! This is USD's convention: points are **row vectors** multiplied on the
13//! left, `p' = [x, y, z, 1] · M`, so the translation is the **last row** and
14//! the last column is `(0, 0, 0, 1)`. Every one of the 41 840 transforms in
15//! the upstream examples (`1a63082`) has that last column. Upstream's viewer
16//! (`src/viewer/render.ts`) loads the rows with three.js `Matrix4.set` and
17//! then transposes, which yields the same mapping.
18//!
19//! Flattening the rows in file order gives the column-major element order
20//! of the equivalent column-vector matrix, which is what glTF `node.matrix`
21//! and three.js `Matrix4.elements` expect; see
22//! [`Transform::to_column_major`].
23//!
24//! A node's transform places its own geometry and all of its children
25//! relative to its parent. In the row-vector convention a child's world
26//! matrix is `local · parent_world`; [`world_from_parent`] computes it, and
27//! a node without the attribute inherits its parent's world matrix. Units
28//! are those of the file (metres in every upstream sample); no scaling is
29//! applied.
30
31use serde_json::Value;
32
33use crate::error::DecodeError;
34use crate::json;
35use crate::math::Vec3;
36
37/// Attribute id that carries a node's local transform.
38pub const ATTRIBUTE: &str = "usd::xformop";
39
40/// An affine 4×4 transform in USD's row-vector layout.
41///
42/// Values are `f64` so that georeferenced translations keep millimetre
43/// precision. The last column is always `(0, 0, 0, 1)`.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct Transform {
46    rows: [[f64; 4]; 4],
47}
48
49impl Default for Transform {
50    fn default() -> Self {
51        Self::IDENTITY
52    }
53}
54
55impl Transform {
56    /// The transform that leaves every point where it is.
57    pub const IDENTITY: Self = Self {
58        rows: [
59            [1.0, 0.0, 0.0, 0.0],
60            [0.0, 1.0, 0.0, 0.0],
61            [0.0, 0.0, 1.0, 0.0],
62            [0.0, 0.0, 0.0, 1.0],
63        ],
64    };
65
66    /// Builds a transform from rows in the file's layout (translation in
67    /// the last row). Fails if the last column is not `(0, 0, 0, 1)` or a
68    /// value is not finite.
69    pub fn from_rows(rows: [[f64; 4]; 4]) -> Result<Self, DecodeError> {
70        for (i, row) in rows.iter().enumerate() {
71            for (j, v) in row.iter().enumerate() {
72                if !v.is_finite() {
73                    return Err(DecodeError::WrongShape {
74                        at: format!("transform[{i}][{j}]"),
75                        expected: "finite number",
76                    });
77                }
78            }
79        }
80        let last_column = [rows[0][3], rows[1][3], rows[2][3], rows[3][3]];
81        if last_column != [0.0, 0.0, 0.0, 1.0] {
82            return Err(DecodeError::NonAffineTransform { last_column });
83        }
84        Ok(Self { rows })
85    }
86
87    /// A pure translation.
88    pub fn from_translation(t: Vec3) -> Self {
89        let mut rows = Self::IDENTITY.rows;
90        rows[3] = [t[0], t[1], t[2], 1.0];
91        Self { rows }
92    }
93
94    /// Decodes the value of a [`ATTRIBUTE`] (`usd::xformop`) attribute, an
95    /// object whose `transform` field holds the matrix.
96    ///
97    /// ```
98    /// use openbim_ifcx_geometry::transform::Transform;
99    ///
100    /// let value = serde_json::json!({"transform": [
101    ///     [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [5, 0, 2, 1]
102    /// ]});
103    /// let t = Transform::from_attribute(&value)?;
104    /// assert_eq!(t.translation(), [5.0, 0.0, 2.0]);
105    /// assert_eq!(t.transform_point([1.0, 1.0, 1.0]), [6.0, 1.0, 3.0]);
106    /// # Ok::<(), openbim_ifcx_geometry::DecodeError>(())
107    /// ```
108    pub fn from_attribute(value: &Value) -> Result<Self, DecodeError> {
109        let object = json::object(value, ATTRIBUTE)?;
110        Self::from_matrix_json(json::field(object, "transform")?)
111    }
112
113    /// Decodes the matrix itself: four arrays of four numbers, rows first.
114    pub fn from_matrix_json(value: &Value) -> Result<Self, DecodeError> {
115        let shape_error = || DecodeError::WrongShape {
116            at: "transform".to_owned(),
117            expected: "array of 4 rows of 4 numbers",
118        };
119        let rows_json = json::array(value, "transform")?;
120        if rows_json.len() != 4 {
121            return Err(shape_error());
122        }
123        let mut rows = [[0.0; 4]; 4];
124        for (i, (row, row_json)) in rows.iter_mut().zip(rows_json).enumerate() {
125            let cells = match row_json.as_array() {
126                Some(cells) if cells.len() == 4 => cells,
127                _ => {
128                    return Err(DecodeError::WrongShape {
129                        at: format!("transform[{i}]"),
130                        expected: "array of 4 numbers",
131                    })
132                }
133            };
134            for (j, (cell, v)) in row.iter_mut().zip(cells).enumerate() {
135                *cell = json::number(v, || format!("transform[{i}][{j}]"))?;
136            }
137        }
138        Self::from_rows(rows)
139    }
140
141    /// The rows in the file's layout; the translation is `rows()[3]`.
142    pub fn rows(&self) -> &[[f64; 4]; 4] {
143        &self.rows
144    }
145
146    /// The translation part.
147    pub fn translation(&self) -> Vec3 {
148        let r = self.rows[3];
149        [r[0], r[1], r[2]]
150    }
151
152    /// The 16 values in column-major order of the equivalent column-vector
153    /// matrix: the layout of glTF `node.matrix` and three.js
154    /// `Matrix4.elements`. This equals the file's rows flattened in order.
155    pub fn to_column_major(&self) -> [f64; 16] {
156        let mut out = [0.0; 16];
157        for (i, row) in self.rows.iter().enumerate() {
158            out[i * 4..i * 4 + 4].copy_from_slice(row);
159        }
160        out
161    }
162
163    /// The transform that applies `self` first and then `outer`; in the
164    /// row-vector layout this is the product `self · outer`.
165    ///
166    /// For a node with local transform `local` under a parent with world
167    /// transform `parent`, the node's world transform is
168    /// `local.then(&parent)`.
169    pub fn then(&self, outer: &Transform) -> Transform {
170        let a = &self.rows;
171        let b = &outer.rows;
172        let mut rows = [[0.0; 4]; 4];
173        for (i, row) in rows.iter_mut().enumerate() {
174            for (j, cell) in row.iter_mut().enumerate() {
175                *cell = (0..4).map(|k| a[i][k] * b[k][j]).sum();
176            }
177        }
178        // Keep the affine column exact rather than accumulating rounding.
179        for row in rows.iter_mut().take(3) {
180            row[3] = 0.0;
181        }
182        rows[3][3] = 1.0;
183        Transform { rows }
184    }
185
186    /// Maps a point, including the translation.
187    pub fn transform_point(&self, p: Vec3) -> Vec3 {
188        let v = self.transform_vector(p);
189        let t = self.rows[3];
190        [v[0] + t[0], v[1] + t[1], v[2] + t[2]]
191    }
192
193    /// Maps a direction, ignoring the translation. Normals need the inverse
194    /// transpose instead when the transform scales unevenly.
195    pub fn transform_vector(&self, v: Vec3) -> Vec3 {
196        let m = &self.rows;
197        [
198            v[0] * m[0][0] + v[1] * m[1][0] + v[2] * m[2][0],
199            v[0] * m[0][1] + v[1] * m[1][1] + v[2] * m[2][1],
200            v[0] * m[0][2] + v[1] * m[1][2] + v[2] * m[2][2],
201        ]
202    }
203
204    /// Determinant of the 3×3 linear part. A negative value means the
205    /// transform mirrors, which reverses triangle winding.
206    pub fn determinant(&self) -> f64 {
207        let m = &self.rows;
208        m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
209            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
210            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
211    }
212}
213
214/// World transform of a node from its parent's world transform and its own
215/// local transform, if it has one. A node without a local transform inherits
216/// its parent's world transform unchanged. Use [`Transform::IDENTITY`] as the
217/// parent of a root.
218pub fn world_from_parent(parent_world: &Transform, local: Option<&Transform>) -> Transform {
219    match local {
220        Some(local) => local.then(parent_world),
221        None => *parent_world,
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use serde_json::json;
229
230    fn close(a: Vec3, b: Vec3) -> bool {
231        a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-12)
232    }
233
234    /// 90° about +Z: x → y, y → -x (row-vector layout).
235    fn rot_z90() -> Transform {
236        Transform::from_rows([
237            [0.0, 1.0, 0.0, 0.0],
238            [-1.0, 0.0, 0.0, 0.0],
239            [0.0, 0.0, 1.0, 0.0],
240            [0.0, 0.0, 0.0, 1.0],
241        ])
242        .unwrap()
243    }
244
245    #[test]
246    fn decodes_translation_from_last_row() {
247        let t = Transform::from_attribute(&json!({"transform": [
248            [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [1.5, -2, 3.25, 1]
249        ]}))
250        .unwrap();
251        assert_eq!(t.translation(), [1.5, -2.0, 3.25]);
252        assert_eq!(t.transform_point([0.0; 3]), [1.5, -2.0, 3.25]);
253        assert_eq!(t.transform_vector([1.0, 0.0, 0.0]), [1.0, 0.0, 0.0]);
254    }
255
256    #[test]
257    fn column_major_matches_file_order() {
258        let t = Transform::from_translation([7.0, 8.0, 9.0]);
259        let m = t.to_column_major();
260        assert_eq!(&m[12..16], &[7.0, 8.0, 9.0, 1.0]);
261        assert_eq!(m[0], 1.0);
262    }
263
264    #[test]
265    fn rotation_maps_rows_as_images_of_axes() {
266        let r = rot_z90();
267        assert!(close(r.transform_point([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0]));
268        assert!(close(r.transform_point([0.0, 1.0, 0.0]), [-1.0, 0.0, 0.0]));
269        assert_eq!(r.determinant(), 1.0);
270    }
271
272    #[test]
273    fn three_level_hierarchy() {
274        // root: translate (10, 0, 0)
275        // └─ a: rotate 90° about Z
276        //    └─ b: no transform
277        //       └─ c: translate (1, 0, 0)
278        let root = world_from_parent(
279            &Transform::IDENTITY,
280            Some(&Transform::from_translation([10.0, 0.0, 0.0])),
281        );
282        let a = world_from_parent(&root, Some(&rot_z90()));
283        let b = world_from_parent(&a, None);
284        let c = world_from_parent(&b, Some(&Transform::from_translation([1.0, 0.0, 0.0])));
285
286        assert_eq!(b, a, "a node without a transform inherits");
287        // c's origin: (1,0,0) → rotate → (0,1,0) → translate → (10,1,0)
288        assert!(close(c.translation(), [10.0, 1.0, 0.0]));
289        assert!(close(c.transform_point([0.0, 1.0, 0.0]), [9.0, 1.0, 0.0]));
290        assert_eq!(
291            c.rows(),
292            &[
293                [0.0, 1.0, 0.0, 0.0],
294                [-1.0, 0.0, 0.0, 0.0],
295                [0.0, 0.0, 1.0, 0.0],
296                [10.0, 1.0, 0.0, 1.0],
297            ]
298        );
299    }
300
301    #[test]
302    fn then_is_associative_with_point_application() {
303        let a = rot_z90();
304        let b = Transform::from_translation([3.0, 4.0, 5.0]);
305        let p = [1.0, 2.0, 3.0];
306        assert!(close(
307            a.then(&b).transform_point(p),
308            b.transform_point(a.transform_point(p))
309        ));
310    }
311
312    #[test]
313    fn rejects_wrong_shapes() {
314        let err = |v: Value| Transform::from_attribute(&v).unwrap_err();
315        assert_eq!(
316            err(json!([[1, 0, 0, 0]])),
317            DecodeError::WrongShape {
318                at: "usd::xformop".into(),
319                expected: "object"
320            }
321        );
322        assert_eq!(
323            err(json!({})),
324            DecodeError::MissingField { field: "transform" }
325        );
326        assert!(matches!(
327            err(json!({"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]]})),
328            DecodeError::WrongShape { .. }
329        ));
330        assert_eq!(
331            err(json!({"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1], [0, 0, 0, 1]]})),
332            DecodeError::WrongShape {
333                at: "transform[2]".into(),
334                expected: "array of 4 numbers"
335            }
336        );
337        assert_eq!(
338            err(json!({"transform": [[1, 0, 0, 0], [0, 1, "x", 0], [0, 0, 1, 0], [0, 0, 0, 1]]})),
339            DecodeError::WrongShape {
340                at: "transform[1][2]".into(),
341                expected: "number"
342            }
343        );
344    }
345
346    #[test]
347    fn rejects_translation_in_last_column() {
348        // Column-vector layout written by mistake.
349        let e = Transform::from_attribute(&json!({"transform": [
350            [1, 0, 0, 5], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]
351        ]}))
352        .unwrap_err();
353        assert_eq!(
354            e,
355            DecodeError::NonAffineTransform {
356                last_column: [5.0, 0.0, 0.0, 1.0]
357            }
358        );
359    }
360}