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    /// Apply this transform to a point.
112    pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
113        [
114            self.basis[0][0] * p[0]
115                + self.basis[1][0] * p[1]
116                + self.basis[2][0] * p[2]
117                + self.origin[0],
118            self.basis[0][1] * p[0]
119                + self.basis[1][1] * p[1]
120                + self.basis[2][1] * p[2]
121                + self.origin[1],
122            self.basis[0][2] * p[0]
123                + self.basis[1][2] * p[1]
124                + self.basis[2][2] * p[2]
125                + self.origin[2],
126        ]
127    }
128
129    /// Apply the linear part only, without translating.
130    ///
131    /// Correct for vectors and tangents. Surface normals under non-uniform scale
132    /// must use `apply_unit_normal` instead.
133    pub fn apply_direction(&self, v: [f64; 3]) -> [f64; 3] {
134        [
135            self.basis[0][0] * v[0] + self.basis[1][0] * v[1] + self.basis[2][0] * v[2],
136            self.basis[0][1] * v[0] + self.basis[1][1] * v[1] + self.basis[2][1] * v[2],
137            self.basis[0][2] * v[0] + self.basis[1][2] * v[1] + self.basis[2][2] * v[2],
138        ]
139    }
140
141    /// Transform a surface normal as a covector and return unit length.
142    ///
143    /// The cofactor form is equivalent to inverse-transpose multiplication but
144    /// avoids explicitly inverting the affine basis. Each basis column is
145    /// normalized independently; logarithmic cofactor weights preserve finite
146    /// nonsingular operators even when products of two axis scales would
147    /// underflow or overflow in `f64`.
148    #[cfg(feature = "lowering")]
149    pub(crate) fn apply_unit_normal(&self, normal: [f64; 3]) -> Option<[f64; 3]> {
150        if normal.iter().any(|value| !value.is_finite()) {
151            return None;
152        }
153
154        let axis_scales = self
155            .basis
156            .map(|axis| axis.into_iter().map(f64::abs).fold(0.0_f64, f64::max));
157        if axis_scales
158            .iter()
159            .any(|scale| !scale.is_finite() || *scale == 0.0)
160        {
161            return None;
162        }
163
164        let [a, b, c] =
165            std::array::from_fn(|index| self.basis[index].map(|value| value / axis_scales[index]));
166        let cofactors = [cross(b, c), cross(c, a), cross(a, b)];
167
168        // Column scaling protects the cofactor weights; row scaling separately
169        // equilibrates the determinant without changing its sign. Pivoted
170        // elimination then avoids multiplying the tiny row scales together,
171        // while fused subtraction retains the residual in near-shear cases.
172        let row_scales = std::array::from_fn::<_, 3, _>(|row| {
173            [a[row], b[row], c[row]]
174                .into_iter()
175                .map(f64::abs)
176                .fold(0.0_f64, f64::max)
177        });
178        if row_scales.contains(&0.0) {
179            return None;
180        }
181        let mut rows: [[f64; 3]; 3] = std::array::from_fn(|row| {
182            std::array::from_fn(|column| [a, b, c][column][row] / row_scales[row])
183        });
184        let mut determinant_orientation = 1.0;
185        for column in 0..3 {
186            let pivot_row = (column..3)
187                .max_by(|&left, &right| {
188                    rows[left][column]
189                        .abs()
190                        .total_cmp(&rows[right][column].abs())
191                })
192                .expect("a non-empty fixed-size pivot range");
193            if rows[pivot_row][column] == 0.0 {
194                return None;
195            }
196            if pivot_row != column {
197                rows.swap(pivot_row, column);
198                determinant_orientation = -determinant_orientation;
199            }
200
201            let pivot = rows[column][column];
202            determinant_orientation *= pivot.signum();
203            for row in (column + 1)..3 {
204                let factor = rows[row][column] / pivot;
205                // Indexed, not iterated: the row being written (`row`) and the
206                // row being read (`column`) are different rows of the same
207                // array, which `iter_mut` cannot express. Clippy suggests a
208                // rewrite here that does not compile.
209                #[allow(clippy::needless_range_loop)]
210                for trailing in (column + 1)..3 {
211                    rows[row][trailing] =
212                        (-factor).mul_add(rows[column][trailing], rows[row][trailing]);
213                }
214            }
215        }
216
217        let pair_scale_logs = [
218            axis_scales[1].ln() + axis_scales[2].ln(),
219            axis_scales[2].ln() + axis_scales[0].ln(),
220            axis_scales[0].ln() + axis_scales[1].ln(),
221        ];
222        let term_logs = std::array::from_fn::<_, 3, _>(|index| {
223            if normal[index] == 0.0 {
224                f64::NEG_INFINITY
225            } else {
226                normal[index].abs().ln() + pair_scale_logs[index]
227            }
228        });
229        let max_term_log = term_logs.into_iter().fold(f64::NEG_INFINITY, f64::max);
230        if !max_term_log.is_finite() {
231            return None;
232        }
233
234        let mut transformed = [0.0; 3];
235        for index in 0..3 {
236            if normal[index] == 0.0 {
237                continue;
238            }
239            let weight = normal[index].signum() * (term_logs[index] - max_term_log).exp();
240            for (component, value) in transformed.iter_mut().enumerate() {
241                *value += cofactors[index][component] * weight;
242            }
243        }
244        normalize(scale(transformed, determinant_orientation))
245    }
246
247    /// Convert to a neutral orthonormal frame at the IFC boundary.
248    ///
249    /// `axiolid_core::Frame3` is structurally public and cannot enforce unit,
250    /// orthogonal, right-handed axes itself. This method is therefore the one
251    /// executable IFC-to-Axiolid frame contract. It rejects non-finite origins,
252    /// scaled or sheared axes, and mirrored frames before neutral construction.
253    /// IFC placements reach this method after `Axis`/`RefDirection` have been
254    /// normalized and Gram-Schmidt orthogonalized; mapped-item scale stays on
255    /// an `Instance` transform and must never leak into a surface frame.
256    #[cfg(feature = "lowering")]
257    pub fn to_geom_frame(
258        self,
259        entity: ifc_model::EntityId,
260    ) -> crate::GeometryResult<axiolid_core::Frame3> {
261        const INVARIANT_EPSILON: f64 = 1e-9;
262
263        let finite_origin = self.origin.iter().all(|value| value.is_finite());
264        let finite_basis = self.basis.iter().flatten().all(|value| value.is_finite());
265        if !finite_origin || !finite_basis {
266            return Err(crate::GeometryError::Degenerate {
267                entity,
268                type_name: "IfcAxis2Placement".to_string(),
269                detail: "neutral frame origin and axes must be finite".to_string(),
270            });
271        }
272
273        let [x, y, z] = self.basis;
274        let unit = [dot(x, x), dot(y, y), dot(z, z)]
275            .into_iter()
276            .all(|length_squared| (length_squared - 1.0).abs() <= INVARIANT_EPSILON);
277        let orthogonal = dot(x, y).abs() <= INVARIANT_EPSILON
278            && dot(x, z).abs() <= INVARIANT_EPSILON
279            && dot(y, z).abs() <= INVARIANT_EPSILON;
280        let right_handed = dot(cross(x, y), z) >= 1.0 - INVARIANT_EPSILON;
281        if !unit || !orthogonal || !right_handed {
282            return Err(crate::GeometryError::Degenerate {
283                entity,
284                type_name: "IfcAxis2Placement".to_string(),
285                detail: "neutral frame axes must be unit, orthogonal, and right-handed".to_string(),
286            });
287        }
288
289        Ok(axiolid_core::Frame3 {
290            origin: axiolid_core::Point3::from_array(self.origin),
291            x: axiolid_core::Vec3::from_array(x),
292            y: axiolid_core::Vec3::from_array(y),
293            z: axiolid_core::Vec3::from_array(z),
294        })
295    }
296
297    /// Convert to the format-neutral geometry transform at the IFC boundary.
298    #[cfg(feature = "lowering")]
299    pub fn to_geom(self) -> axiolid_core::Transform3 {
300        let columns = self.basis.map(axiolid_core::Vec3::from_array);
301        axiolid_core::Transform3::from_mat3_translation(
302            axiolid_core::Mat3::from_cols(columns[0], columns[1], columns[2]),
303            axiolid_core::Vec3::from_array(self.origin),
304        )
305    }
306
307    /// Compose: `self` applied after `inner`.
308    ///
309    /// This is the operation a placement chain folds with. Order matters and
310    /// getting it backwards places every child relative to the wrong parent,
311    /// so the convention is stated here once: `parent.compose(&child)` yields
312    /// the child's world transform.
313    pub fn compose(&self, inner: &Transform) -> Transform {
314        Transform {
315            basis: [
316                self.apply_direction(inner.basis[0]),
317                self.apply_direction(inner.basis[1]),
318                self.apply_direction(inner.basis[2]),
319            ],
320            origin: self.apply(inner.origin),
321        }
322    }
323
324    /// Scale the linear part uniformly, e.g. for a transformation operator.
325    pub fn scaled(&self, factor: f64) -> Transform {
326        Transform {
327            basis: [
328                scale(self.basis[0], factor),
329                scale(self.basis[1], factor),
330                scale(self.basis[2], factor),
331            ],
332            origin: self.origin,
333        }
334    }
335
336    /// Scale each axis independently, for the non-uniform operator.
337    pub fn scaled_nonuniform(&self, factors: [f64; 3]) -> Transform {
338        Transform {
339            basis: [
340                scale(self.basis[0], factors[0]),
341                scale(self.basis[1], factors[1]),
342                scale(self.basis[2], factors[2]),
343            ],
344            origin: self.origin,
345        }
346    }
347
348    /// Convert the translation to metres, leaving the basis dimensionless.
349    ///
350    /// IFC coordinates carry the file's length unit; direction ratios and
351    /// scale factors do not. Scaling the basis as well would compound the
352    /// unit into every rotation and silently resize geometry, so only the
353    /// origin is converted. Apply this exactly once, at the boundary where a
354    /// source frame becomes project space.
355    pub fn to_metres(self, units: &crate::units::UnitScale) -> Transform {
356        Transform {
357            basis: self.basis,
358            origin: self.origin.map(|coordinate| units.length(coordinate)),
359        }
360    }
361
362    /// Is this within tolerance of the identity?
363    pub fn is_identity(&self, tolerance: f64) -> bool {
364        let id = Transform::identity();
365        self.origin
366            .iter()
367            .zip(id.origin)
368            .all(|(a, b)| (a - b).abs() <= tolerance)
369            && self
370                .basis
371                .iter()
372                .flatten()
373                .zip(id.basis.iter().flatten())
374                .all(|(a, b)| (a - b).abs() <= tolerance)
375    }
376}
377
378/// A sensible local X when `RefDirection` is omitted.
379///
380/// The spec says the default is the projection of the global X axis; when the
381/// local Z *is* global X, that degenerates, so global Z is used instead.
382fn default_ref_direction(z: [f64; 3]) -> [f64; 3] {
383    if z[0].abs() > 0.9 {
384        [0.0, 0.0, 1.0]
385    } else {
386        [1.0, 0.0, 0.0]
387    }
388}
389
390fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
391    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
392}
393
394fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
395    [
396        a[1] * b[2] - a[2] * b[1],
397        a[2] * b[0] - a[0] * b[2],
398        a[0] * b[1] - a[1] * b[0],
399    ]
400}
401
402fn scale(v: [f64; 3], f: f64) -> [f64; 3] {
403    [v[0] * f, v[1] * f, v[2] * f]
404}
405
406/// Normalize, or `None` if the vector is zero or non-finite.
407fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
408    let scale = v
409        .iter()
410        .map(|component| component.abs())
411        .fold(0.0, f64::max);
412    if scale == 0.0 || !scale.is_finite() {
413        return None;
414    }
415    let scaled = [v[0] / scale, v[1] / scale, v[2] / scale];
416    let length = dot(scaled, scaled).sqrt();
417    Some([scaled[0] / length, scaled[1] / length, scaled[2] / length])
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn close(a: [f64; 3], b: [f64; 3]) -> bool {
425        a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)
426    }
427
428    #[test]
429    fn identity_leaves_points_alone() {
430        assert!(close(
431            Transform::identity().apply([1.0, 2.0, 3.0]),
432            [1.0, 2.0, 3.0]
433        ));
434    }
435
436    #[test]
437    fn translation_moves_points_but_not_directions() {
438        let t = Transform::translation([10.0, 0.0, 0.0]);
439        assert!(close(t.apply([1.0, 0.0, 0.0]), [11.0, 0.0, 0.0]));
440        assert!(
441            close(t.apply_direction([1.0, 0.0, 0.0]), [1.0, 0.0, 0.0]),
442            "a direction must not be translated"
443        );
444    }
445
446    /// The spec allows RefDirection to be non-perpendicular to Axis and
447    /// requires projecting it. Skipping that yields a sheared basis.
448    #[test]
449    fn non_perpendicular_ref_direction_is_projected_not_used_raw() {
450        let t = Transform::from_axes(
451            [0.0, 0.0, 0.0],
452            Some([0.0, 0.0, 1.0]),
453            Some([1.0, 0.0, 0.5]), // deliberately not perpendicular to Z
454        )
455        .unwrap();
456
457        assert!(
458            close(t.basis[0], [1.0, 0.0, 0.0]),
459            "X must be projected into the plane normal to Z, got {:?}",
460            t.basis[0]
461        );
462        assert!(
463            (dot(t.basis[0], t.basis[2])).abs() < 1e-12,
464            "basis must be orthogonal"
465        );
466    }
467
468    #[test]
469    fn axes_default_to_the_global_frame() {
470        let t = Transform::from_axes([0.0, 0.0, 0.0], None, None).unwrap();
471        assert!(t.is_identity(1e-12));
472    }
473
474    #[test]
475    fn degenerate_axes_are_rejected_rather_than_producing_nonsense() {
476        assert!(Transform::from_axes([0.0; 3], Some([0.0, 0.0, 0.0]), None).is_none());
477        // RefDirection parallel to Axis leaves nothing to project.
478        assert!(
479            Transform::from_axes([0.0; 3], Some([0.0, 0.0, 1.0]), Some([0.0, 0.0, 1.0])).is_none()
480        );
481    }
482
483    /// A storey at z=3 containing a wall at z=1 puts the wall at z=4.
484    #[test]
485    fn composition_stacks_translations() {
486        let storey = Transform::translation([0.0, 0.0, 3.0]);
487        let wall = Transform::translation([0.0, 0.0, 1.0]);
488        assert!(close(storey.compose(&wall).origin, [0.0, 0.0, 4.0]));
489    }
490
491    /// Composition is not commutative; the convention must hold.
492    #[test]
493    fn composition_applies_rotation_to_the_child_offset() {
494        // Parent rotated 90 degrees about Z.
495        let parent = Transform {
496            basis: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
497            origin: [0.0, 0.0, 0.0],
498        };
499        let child = Transform::translation([1.0, 0.0, 0.0]);
500        let world = parent.compose(&child);
501        assert!(
502            close(world.origin, [0.0, 1.0, 0.0]),
503            "child X offset must rotate into parent Y, got {:?}",
504            world.origin
505        );
506    }
507
508    #[test]
509    fn non_uniform_scale_is_representable() {
510        let t = Transform::identity().scaled_nonuniform([2.0, 3.0, 4.0]);
511        assert!(close(t.apply([1.0, 1.0, 1.0]), [2.0, 3.0, 4.0]));
512    }
513
514    #[cfg(feature = "lowering")]
515    #[test]
516    fn finite_extreme_non_uniform_scales_preserve_normal_directions() {
517        let transform = Transform::identity().scaled_nonuniform([1.0, 1e-200, 1e-200]);
518
519        assert!(close(
520            transform
521                .apply_unit_normal([0.0, 0.0, 1.0])
522                .expect("finite nonsingular transforms preserve normals"),
523            [0.0, 0.0, 1.0]
524        ));
525        assert!(close(
526            transform
527                .apply_unit_normal([1.0, 0.0, 0.0])
528                .expect("a tiny cofactor is still a valid direction"),
529            [1.0, 0.0, 0.0]
530        ));
531    }
532
533    #[cfg(feature = "lowering")]
534    #[test]
535    fn finite_extreme_shear_uses_a_scale_aware_determinant_sign() {
536        let transform = Transform {
537            basis: [[1.0, 0.0, 0.0], [1.0, 1e-200, 0.0], [1.0, 0.0, 1e-200]],
538            origin: [0.0; 3],
539        };
540
541        assert!(close(
542            transform
543                .apply_unit_normal([0.0, 0.0, 1.0])
544                .expect("finite nonsingular shear preserves normals"),
545            [0.0, 0.0, 1.0]
546        ));
547    }
548
549    #[cfg(feature = "lowering")]
550    #[test]
551    fn neutral_frames_enforce_the_ifc_to_axiolid_axis_invariant() {
552        let id = ifc_model::EntityId(7);
553        assert!(Transform::identity().to_geom_frame(id).is_ok());
554
555        let invalid = [
556            Transform {
557                basis: [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
558                origin: [0.0; 3],
559            },
560            Transform {
561                basis: [[1.0, 0.0, 0.0], [0.5, 1.0, 0.0], [0.0, 0.0, 1.0]],
562                origin: [0.0; 3],
563            },
564            Transform {
565                basis: [[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]],
566                origin: [0.0; 3],
567            },
568            Transform {
569                basis: Transform::identity().basis,
570                origin: [f64::NAN, 0.0, 0.0],
571            },
572        ];
573
574        for transform in invalid {
575            let error = transform
576                .to_geom_frame(id)
577                .expect_err("invalid axes must not reach axiolid_core::Frame3");
578            assert!(
579                matches!(error, crate::GeometryError::Degenerate { entity, .. } if entity == id)
580            );
581        }
582    }
583}