Skip to main content

ifc_geometry/
transform.rs

1//! Rigid transforms: the composition algebra placements reduce to.
2//!
3//! IFC expresses position as nested `IfcAxis2Placement3D` inside
4//! `IfcLocalPlacement` chains, plus `IfcCartesianTransformationOperator` for
5//! mapped items. All of it collapses to a 4x3 affine transform, which is what
6//! this module provides.
7//!
8//! # Why 4x3 and not 4x4
9//!
10//! The bottom row of an IFC transform is always `[0,0,0,1]`: there is no
11//! projective component. Storing it would invite code that reads it, and a
12//! non-affine transform in a building model is always a bug.
13//!
14//! Non-uniform scale IS representable, because
15//! `IfcCartesianTransformationOperator3DnonUniform` exists.
16
17/// An affine transform: a 3x3 linear part plus a translation.
18///
19/// Column-major: `basis[i]` is the image of basis vector `i`.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct Transform {
22    /// Images of the X, Y, Z basis vectors.
23    pub basis: [[f64; 3]; 3],
24    /// Translation applied after the linear part.
25    pub origin: [f64; 3],
26}
27
28impl Default for Transform {
29    fn default() -> Self {
30        Self::identity()
31    }
32}
33
34impl Transform {
35    /// The identity transform.
36    pub const fn identity() -> Self {
37        Self {
38            basis: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
39            origin: [0.0, 0.0, 0.0],
40        }
41    }
42
43    /// A pure translation.
44    pub const fn translation(origin: [f64; 3]) -> Self {
45        Self {
46            basis: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
47            origin,
48        }
49    }
50
51    /// Build from an origin and axis directions, Gram-Schmidt orthonormalized.
52    ///
53    /// IFC gives `Axis` (local Z) and `RefDirection` (approximate local X) and
54    /// explicitly allows them to be non-perpendicular: the spec derives X by
55    /// projecting `RefDirection` onto the plane normal to `Axis`. Skipping
56    /// that projection produces a sheared transform that looks almost right,
57    /// which is worse than looking obviously wrong.
58    ///
59    /// Returns `None` if the axes are degenerate (zero-length or parallel).
60    ///
61    /// This is the schema's `IfcBuildAxes`; the two derived axes come from
62    /// `IfcFirstProjAxis` and `IfcSecondProjAxis`, marked inline below.
63    pub fn from_axes(
64        origin: [f64; 3],
65        axis: Option<[f64; 3]>,
66        ref_direction: Option<[f64; 3]>,
67    ) -> Option<Self> {
68        let z = normalize(axis.unwrap_or([0.0, 0.0, 1.0]))?;
69        let reference = ref_direction.unwrap_or_else(|| default_ref_direction(z));
70
71        // `IfcFirstProjAxis`: project the reference direction onto the plane
72        // normal to z, so a file's non-perpendicular RefDirection still
73        // yields an orthonormal frame rather than a skewed one.
74        let dot = dot(reference, z);
75        let projected = [
76            reference[0] - dot * z[0],
77            reference[1] - dot * z[1],
78            reference[2] - dot * z[2],
79        ];
80        let x = normalize(projected)?;
81        // `IfcSecondProjAxis`: the schema subtracts the z and x components
82        // from a Y hint, which for an orthonormal z and x is exactly z X x.
83        let y = cross(z, x);
84
85        Some(Self {
86            basis: [x, y, z],
87            origin,
88        })
89    }
90
91    /// [`Self::from_axes`], falling back to the identity transform when the
92    /// axes are absent or degenerate.
93    ///
94    /// IFC's `Axis`/`RefDirection` are both optional and, per spec, default to
95    /// the standard basis when omitted — the common case, so most call sites
96    /// that use `from_axes` immediately follow it with
97    /// `.unwrap_or_else(Transform::identity)`. This collapses that
98    /// boilerplate. Reach for `from_axes` directly when a degenerate axis
99    /// pair should be a reportable error instead of a silent identity.
100    pub fn from_axes_or_identity(
101        origin: [f64; 3],
102        axis: Option<[f64; 3]>,
103        ref_direction: Option<[f64; 3]>,
104    ) -> Self {
105        Self::from_axes(origin, axis, ref_direction).unwrap_or(Self {
106            basis: Self::identity().basis,
107            origin,
108        })
109    }
110
111    /// The determinant of the linear part: the signed volume scale.
112    ///
113    /// Negative when the transform mirrors (reverses handedness), positive
114    /// when it preserves it; its magnitude is the volume scale factor. Zero or
115    /// non-finite only for a degenerate basis, which an IFC placement or a
116    /// cartesian transformation operator cannot state validly (`Scale` is
117    /// positive and the axes are independent).
118    pub fn determinant(&self) -> f64 {
119        dot(self.basis[0], cross(self.basis[1], self.basis[2]))
120    }
121
122    /// Apply this transform to a point.
123    pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
124        [
125            self.basis[0][0] * p[0]
126                + self.basis[1][0] * p[1]
127                + self.basis[2][0] * p[2]
128                + self.origin[0],
129            self.basis[0][1] * p[0]
130                + self.basis[1][1] * p[1]
131                + self.basis[2][1] * p[2]
132                + self.origin[1],
133            self.basis[0][2] * p[0]
134                + self.basis[1][2] * p[1]
135                + self.basis[2][2] * p[2]
136                + self.origin[2],
137        ]
138    }
139
140    /// Apply the linear part only, without translating.
141    ///
142    /// Correct for vectors and tangents. Surface normals under non-uniform scale
143    /// must use `apply_unit_normal` instead.
144    pub fn apply_direction(&self, v: [f64; 3]) -> [f64; 3] {
145        [
146            self.basis[0][0] * v[0] + self.basis[1][0] * v[1] + self.basis[2][0] * v[2],
147            self.basis[0][1] * v[0] + self.basis[1][1] * v[1] + self.basis[2][1] * v[2],
148            self.basis[0][2] * v[0] + self.basis[1][2] * v[1] + self.basis[2][2] * v[2],
149        ]
150    }
151
152    /// Transform a surface normal as a covector and return unit length.
153    ///
154    /// The cofactor form is equivalent to inverse-transpose multiplication but
155    /// avoids explicitly inverting the affine basis. Each basis column is
156    /// normalized independently; logarithmic cofactor weights preserve finite
157    /// nonsingular operators even when products of two axis scales would
158    /// underflow or overflow in `f64`.
159    #[cfg(feature = "lowering")]
160    pub(crate) fn apply_unit_normal(&self, normal: [f64; 3]) -> Option<[f64; 3]> {
161        if normal.iter().any(|value| !value.is_finite()) {
162            return None;
163        }
164
165        let axis_scales = self
166            .basis
167            .map(|axis| axis.into_iter().map(f64::abs).fold(0.0_f64, f64::max));
168        if axis_scales
169            .iter()
170            .any(|scale| !scale.is_finite() || *scale == 0.0)
171        {
172            return None;
173        }
174
175        let [a, b, c] =
176            std::array::from_fn(|index| self.basis[index].map(|value| value / axis_scales[index]));
177        let cofactors = [cross(b, c), cross(c, a), cross(a, b)];
178
179        // Column scaling protects the cofactor weights; row scaling separately
180        // equilibrates the determinant without changing its sign. Pivoted
181        // elimination then avoids multiplying the tiny row scales together,
182        // while fused subtraction retains the residual in near-shear cases.
183        let row_scales = std::array::from_fn::<_, 3, _>(|row| {
184            [a[row], b[row], c[row]]
185                .into_iter()
186                .map(f64::abs)
187                .fold(0.0_f64, f64::max)
188        });
189        if row_scales.contains(&0.0) {
190            return None;
191        }
192        let mut rows: [[f64; 3]; 3] = std::array::from_fn(|row| {
193            std::array::from_fn(|column| [a, b, c][column][row] / row_scales[row])
194        });
195        let mut determinant_orientation = 1.0;
196        for column in 0..3 {
197            let pivot_row = (column..3)
198                .max_by(|&left, &right| {
199                    rows[left][column]
200                        .abs()
201                        .total_cmp(&rows[right][column].abs())
202                })
203                .expect("a non-empty fixed-size pivot range");
204            if rows[pivot_row][column] == 0.0 {
205                return None;
206            }
207            if pivot_row != column {
208                rows.swap(pivot_row, column);
209                determinant_orientation = -determinant_orientation;
210            }
211
212            let pivot = rows[column][column];
213            determinant_orientation *= pivot.signum();
214            for row in (column + 1)..3 {
215                let factor = rows[row][column] / pivot;
216                // Indexed, not iterated: the row being written (`row`) and the
217                // row being read (`column`) are different rows of the same
218                // array, which `iter_mut` cannot express. Clippy suggests a
219                // rewrite here that does not compile.
220                #[allow(clippy::needless_range_loop)]
221                for trailing in (column + 1)..3 {
222                    rows[row][trailing] =
223                        (-factor).mul_add(rows[column][trailing], rows[row][trailing]);
224                }
225            }
226        }
227
228        let pair_scale_logs = [
229            axis_scales[1].ln() + axis_scales[2].ln(),
230            axis_scales[2].ln() + axis_scales[0].ln(),
231            axis_scales[0].ln() + axis_scales[1].ln(),
232        ];
233        let term_logs = std::array::from_fn::<_, 3, _>(|index| {
234            if normal[index] == 0.0 {
235                f64::NEG_INFINITY
236            } else {
237                normal[index].abs().ln() + pair_scale_logs[index]
238            }
239        });
240        let max_term_log = term_logs.into_iter().fold(f64::NEG_INFINITY, f64::max);
241        if !max_term_log.is_finite() {
242            return None;
243        }
244
245        let mut transformed = [0.0; 3];
246        for index in 0..3 {
247            if normal[index] == 0.0 {
248                continue;
249            }
250            let weight = normal[index].signum() * (term_logs[index] - max_term_log).exp();
251            for (component, value) in transformed.iter_mut().enumerate() {
252                *value += cofactors[index][component] * weight;
253            }
254        }
255        normalize(scale(transformed, determinant_orientation))
256    }
257
258    /// Convert to a neutral orthonormal frame at the IFC boundary.
259    ///
260    /// `axiolid_core::Frame3` is structurally public and cannot enforce unit,
261    /// orthogonal, right-handed axes itself. This method is therefore the one
262    /// executable IFC-to-Axiolid frame contract. It rejects non-finite origins,
263    /// scaled or sheared axes, and mirrored frames before neutral construction.
264    /// IFC placements reach this method after `Axis`/`RefDirection` have been
265    /// normalized and Gram-Schmidt orthogonalized; mapped-item scale stays on
266    /// an `Instance` transform and must never leak into a surface frame.
267    #[cfg(feature = "lowering")]
268    pub fn to_geom_frame(
269        self,
270        entity: ifc_model::EntityId,
271    ) -> crate::GeometryResult<axiolid_core::Frame3> {
272        const INVARIANT_EPSILON: f64 = 1e-9;
273
274        let finite_origin = self.origin.iter().all(|value| value.is_finite());
275        let finite_basis = self.basis.iter().flatten().all(|value| value.is_finite());
276        if !finite_origin || !finite_basis {
277            return Err(crate::GeometryError::Degenerate {
278                entity,
279                type_name: "IfcAxis2Placement".to_string(),
280                detail: "neutral frame origin and axes must be finite".to_string(),
281            });
282        }
283
284        let [x, y, z] = self.basis;
285        let unit = [dot(x, x), dot(y, y), dot(z, z)]
286            .into_iter()
287            .all(|length_squared| (length_squared - 1.0).abs() <= INVARIANT_EPSILON);
288        let orthogonal = dot(x, y).abs() <= INVARIANT_EPSILON
289            && dot(x, z).abs() <= INVARIANT_EPSILON
290            && dot(y, z).abs() <= INVARIANT_EPSILON;
291        let right_handed = dot(cross(x, y), z) >= 1.0 - INVARIANT_EPSILON;
292        if !unit || !orthogonal || !right_handed {
293            return Err(crate::GeometryError::Degenerate {
294                entity,
295                type_name: "IfcAxis2Placement".to_string(),
296                detail: "neutral frame axes must be unit, orthogonal, and right-handed".to_string(),
297            });
298        }
299
300        Ok(axiolid_core::Frame3 {
301            origin: axiolid_core::Point3::from_array(self.origin),
302            x: axiolid_core::Vec3::from_array(x),
303            y: axiolid_core::Vec3::from_array(y),
304            z: axiolid_core::Vec3::from_array(z),
305        })
306    }
307
308    /// Convert to the format-neutral geometry transform at the IFC boundary.
309    #[cfg(feature = "lowering")]
310    pub fn to_geom(self) -> axiolid_core::Transform3 {
311        let columns = self.basis.map(axiolid_core::Vec3::from_array);
312        axiolid_core::Transform3::from_mat3_translation(
313            axiolid_core::Mat3::from_cols(columns[0], columns[1], columns[2]),
314            axiolid_core::Vec3::from_array(self.origin),
315        )
316    }
317
318    /// Compose: `self` applied after `inner`.
319    ///
320    /// This is the operation a placement chain folds with. Order matters and
321    /// getting it backwards places every child relative to the wrong parent,
322    /// so the convention is stated here once: `parent.compose(&child)` yields
323    /// the child's world transform.
324    pub fn compose(&self, inner: &Transform) -> Transform {
325        Transform {
326            basis: [
327                self.apply_direction(inner.basis[0]),
328                self.apply_direction(inner.basis[1]),
329                self.apply_direction(inner.basis[2]),
330            ],
331            origin: self.apply(inner.origin),
332        }
333    }
334
335    /// Scale the linear part uniformly, e.g. for a transformation operator.
336    pub fn scaled(&self, factor: f64) -> Transform {
337        Transform {
338            basis: [
339                scale(self.basis[0], factor),
340                scale(self.basis[1], factor),
341                scale(self.basis[2], factor),
342            ],
343            origin: self.origin,
344        }
345    }
346
347    /// Scale each axis independently, for the non-uniform operator.
348    pub fn scaled_nonuniform(&self, factors: [f64; 3]) -> Transform {
349        Transform {
350            basis: [
351                scale(self.basis[0], factors[0]),
352                scale(self.basis[1], factors[1]),
353                scale(self.basis[2], factors[2]),
354            ],
355            origin: self.origin,
356        }
357    }
358
359    /// Convert the translation to metres, leaving the basis dimensionless.
360    ///
361    /// IFC coordinates carry the file's length unit; direction ratios and
362    /// scale factors do not. Scaling the basis as well would compound the
363    /// unit into every rotation and silently resize geometry, so only the
364    /// origin is converted. Apply this exactly once, at the boundary where a
365    /// source frame becomes project space.
366    pub fn to_metres(self, units: &crate::units::UnitScale) -> Transform {
367        Transform {
368            basis: self.basis,
369            origin: self.origin.map(|coordinate| units.length(coordinate)),
370        }
371    }
372
373    /// Is this within tolerance of the identity?
374    pub fn is_identity(&self, tolerance: f64) -> bool {
375        let id = Transform::identity();
376        self.origin
377            .iter()
378            .zip(id.origin)
379            .all(|(a, b)| (a - b).abs() <= tolerance)
380            && self
381                .basis
382                .iter()
383                .flatten()
384                .zip(id.basis.iter().flatten())
385                .all(|(a, b)| (a - b).abs() <= tolerance)
386    }
387}
388
389/// A sensible local X when `RefDirection` is omitted.
390///
391/// The spec says the default is the projection of the global X axis; when the
392/// local Z *is* global X, that degenerates, so global Z is used instead.
393fn default_ref_direction(z: [f64; 3]) -> [f64; 3] {
394    if z[0].abs() > 0.9 {
395        [0.0, 0.0, 1.0]
396    } else {
397        [1.0, 0.0, 0.0]
398    }
399}
400
401fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
402    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
403}
404
405fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
406    [
407        a[1] * b[2] - a[2] * b[1],
408        a[2] * b[0] - a[0] * b[2],
409        a[0] * b[1] - a[1] * b[0],
410    ]
411}
412
413fn scale(v: [f64; 3], f: f64) -> [f64; 3] {
414    [v[0] * f, v[1] * f, v[2] * f]
415}
416
417/// Normalize, or `None` if the vector is zero or non-finite.
418fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
419    let scale = v
420        .iter()
421        .map(|component| component.abs())
422        .fold(0.0, f64::max);
423    if scale == 0.0 || !scale.is_finite() {
424        return None;
425    }
426    let scaled = [v[0] / scale, v[1] / scale, v[2] / scale];
427    let length = dot(scaled, scaled).sqrt();
428    Some([scaled[0] / length, scaled[1] / length, scaled[2] / length])
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn close(a: [f64; 3], b: [f64; 3]) -> bool {
436        a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)
437    }
438
439    #[test]
440    fn identity_leaves_points_alone() {
441        assert!(close(
442            Transform::identity().apply([1.0, 2.0, 3.0]),
443            [1.0, 2.0, 3.0]
444        ));
445    }
446
447    #[test]
448    fn translation_moves_points_but_not_directions() {
449        let t = Transform::translation([10.0, 0.0, 0.0]);
450        assert!(close(t.apply([1.0, 0.0, 0.0]), [11.0, 0.0, 0.0]));
451        assert!(
452            close(t.apply_direction([1.0, 0.0, 0.0]), [1.0, 0.0, 0.0]),
453            "a direction must not be translated"
454        );
455    }
456
457    /// The spec allows RefDirection to be non-perpendicular to Axis and
458    /// requires projecting it. Skipping that yields a sheared basis.
459    #[test]
460    fn non_perpendicular_ref_direction_is_projected_not_used_raw() {
461        let t = Transform::from_axes(
462            [0.0, 0.0, 0.0],
463            Some([0.0, 0.0, 1.0]),
464            Some([1.0, 0.0, 0.5]), // deliberately not perpendicular to Z
465        )
466        .unwrap();
467
468        assert!(
469            close(t.basis[0], [1.0, 0.0, 0.0]),
470            "X must be projected into the plane normal to Z, got {:?}",
471            t.basis[0]
472        );
473        assert!(
474            (dot(t.basis[0], t.basis[2])).abs() < 1e-12,
475            "basis must be orthogonal"
476        );
477    }
478
479    #[test]
480    fn axes_default_to_the_global_frame() {
481        let t = Transform::from_axes([0.0, 0.0, 0.0], None, None).unwrap();
482        assert!(t.is_identity(1e-12));
483    }
484
485    #[test]
486    fn degenerate_axes_are_rejected_rather_than_producing_nonsense() {
487        assert!(Transform::from_axes([0.0; 3], Some([0.0, 0.0, 0.0]), None).is_none());
488        // RefDirection parallel to Axis leaves nothing to project.
489        assert!(
490            Transform::from_axes([0.0; 3], Some([0.0, 0.0, 1.0]), Some([0.0, 0.0, 1.0])).is_none()
491        );
492    }
493
494    /// A storey at z=3 containing a wall at z=1 puts the wall at z=4.
495    #[test]
496    fn composition_stacks_translations() {
497        let storey = Transform::translation([0.0, 0.0, 3.0]);
498        let wall = Transform::translation([0.0, 0.0, 1.0]);
499        assert!(close(storey.compose(&wall).origin, [0.0, 0.0, 4.0]));
500    }
501
502    /// Composition is not commutative; the convention must hold.
503    #[test]
504    fn composition_applies_rotation_to_the_child_offset() {
505        // Parent rotated 90 degrees about Z.
506        let parent = Transform {
507            basis: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
508            origin: [0.0, 0.0, 0.0],
509        };
510        let child = Transform::translation([1.0, 0.0, 0.0]);
511        let world = parent.compose(&child);
512        assert!(
513            close(world.origin, [0.0, 1.0, 0.0]),
514            "child X offset must rotate into parent Y, got {:?}",
515            world.origin
516        );
517    }
518
519    #[test]
520    fn non_uniform_scale_is_representable() {
521        let t = Transform::identity().scaled_nonuniform([2.0, 3.0, 4.0]);
522        assert!(close(t.apply([1.0, 1.0, 1.0]), [2.0, 3.0, 4.0]));
523    }
524
525    #[cfg(feature = "lowering")]
526    #[test]
527    fn finite_extreme_non_uniform_scales_preserve_normal_directions() {
528        let transform = Transform::identity().scaled_nonuniform([1.0, 1e-200, 1e-200]);
529
530        assert!(close(
531            transform
532                .apply_unit_normal([0.0, 0.0, 1.0])
533                .expect("finite nonsingular transforms preserve normals"),
534            [0.0, 0.0, 1.0]
535        ));
536        assert!(close(
537            transform
538                .apply_unit_normal([1.0, 0.0, 0.0])
539                .expect("a tiny cofactor is still a valid direction"),
540            [1.0, 0.0, 0.0]
541        ));
542    }
543
544    #[cfg(feature = "lowering")]
545    #[test]
546    fn finite_extreme_shear_uses_a_scale_aware_determinant_sign() {
547        let transform = Transform {
548            basis: [[1.0, 0.0, 0.0], [1.0, 1e-200, 0.0], [1.0, 0.0, 1e-200]],
549            origin: [0.0; 3],
550        };
551
552        assert!(close(
553            transform
554                .apply_unit_normal([0.0, 0.0, 1.0])
555                .expect("finite nonsingular shear preserves normals"),
556            [0.0, 0.0, 1.0]
557        ));
558    }
559
560    #[cfg(feature = "lowering")]
561    #[test]
562    fn neutral_frames_enforce_the_ifc_to_axiolid_axis_invariant() {
563        let id = ifc_model::EntityId(7);
564        assert!(Transform::identity().to_geom_frame(id).is_ok());
565
566        let invalid = [
567            Transform {
568                basis: [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
569                origin: [0.0; 3],
570            },
571            Transform {
572                basis: [[1.0, 0.0, 0.0], [0.5, 1.0, 0.0], [0.0, 0.0, 1.0]],
573                origin: [0.0; 3],
574            },
575            Transform {
576                basis: [[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]],
577                origin: [0.0; 3],
578            },
579            Transform {
580                basis: Transform::identity().basis,
581                origin: [f64::NAN, 0.0, 0.0],
582            },
583        ];
584
585        for transform in invalid {
586            let error = transform
587                .to_geom_frame(id)
588                .expect_err("invalid axes must not reach axiolid_core::Frame3");
589            assert!(
590                matches!(error, crate::GeometryError::Degenerate { entity, .. } if entity == id)
591            );
592        }
593    }
594}