Skip to main content

ifc_geometry/resource/
operator.rs

1//! `IfcCartesianTransformationOperator`: the mapped-item transform.
2//!
3//! An `IfcMappedItem` reuses one representation many times (a door type placed
4//! 400 times) and each instance carries an operator saying how to move, rotate
5//! and scale the shared geometry. That makes this the one place in IFC where a
6//! transform may be **scaled**, including non-uniformly, so it cannot be
7//! folded into the rigid placement path.
8//!
9//! # Slots are shared across the whole family
10//!
11//! `Axis1`, `Axis2`, `LocalOrigin` and `Scale` are declared by the abstract
12//! supertype, so they occupy slots 0..=3 in *every* concrete subtype. The
13//! subtypes append: `Axis3` at 4 (3D), `Scale2` at 4 (2D non-uniform), and
14//! `Axis3`, `Scale2`, `Scale3` at 4..=6 (3D non-uniform). Note that the 2D and
15//! 3D non-uniform variants put `Scale2` at *different* indices.
16//!
17//! # Scale defaults chain
18//!
19//! `Scl := NVL(Scale, 1.0)`, then `Scl2 := NVL(Scale2, Scl)` and
20//! `Scl3 := NVL(Scale3, Scl)`. So a non-uniform operator with only `Scale` set
21//! is uniform, and `Scale2` defaults to `Scale`, **not** to 1.0. Defaulting the
22//! secondary axes to 1.0 silently squashes every instance of the mapped item.
23//!
24//! # Axis1 is X, Axis2 is Y (unlike a placement)
25//!
26//! A placement gives Z first (`Axis`) and X second (`RefDirection`). An
27//! operator gives X first (`Axis1`), Y second (`Axis2`), Z last (`Axis3`).
28//! Reading them in placement order transposes the frame.
29
30use crate::error::{GeometryError, GeometryResult};
31use crate::resource::axes::{base_axes_2d, base_axes_3d};
32use crate::resource::direction::resolve_unit;
33use crate::resource::point::cartesian_point_3d;
34use crate::slots::Slots;
35use crate::transform::Transform;
36use ifc_model::{Entity, EntityId, Model};
37
38/// Attribute slots as ABSOLUTE STEP positions, inherited attributes first.
39pub(crate) mod slot {
40    /// `Axis1 : OPTIONAL IfcDirection` (supertype; local X).
41    pub const AXIS1: usize = 0;
42    /// `Axis2 : OPTIONAL IfcDirection` (supertype; local Y).
43    pub const AXIS2: usize = 1;
44    /// `LocalOrigin : IfcCartesianPoint` (supertype).
45    pub const LOCAL_ORIGIN: usize = 2;
46    /// `Scale : OPTIONAL IfcReal` (supertype).
47    pub const SCALE: usize = 3;
48
49    /// `Axis3 : OPTIONAL IfcDirection`, declared by
50    /// `IfcCartesianTransformationOperator3D` and inherited by its non-uniform
51    /// subtype, so index 4 in both.
52    pub const AXIS3: usize = 4;
53
54    /// `Scale2` on `IfcCartesianTransformationOperator2DnonUniform`.
55    ///
56    /// Index 4 here because the 2D branch has no `Axis3`; the 3D non-uniform
57    /// variant puts the same-named attribute at 5.
58    pub const SCALE2_2D: usize = 4;
59
60    /// `Scale2` on `IfcCartesianTransformationOperator3DnonUniform`.
61    pub const SCALE2_3D: usize = 5;
62    /// `Scale3` on `IfcCartesianTransformationOperator3DnonUniform`.
63    pub const SCALE3_3D: usize = 6;
64}
65
66/// The attributes every operator shares, from the abstract supertype.
67///
68/// A view in its own right so a caller holding an operator of unknown subtype
69/// can still read the origin and scale without dispatching, and so the four
70/// concrete views do not each re-derive the default chain.
71#[derive(Debug, Clone, Copy)]
72pub struct CartesianTransformationOperator<'m> {
73    slots: Slots<'m>,
74}
75
76impl<'m> CartesianTransformationOperator<'m> {
77    /// Wrap an entity assumed to be an `IfcCartesianTransformationOperator`
78    /// subtype.
79    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
80        Self {
81            slots: Slots::new(id, entity),
82        }
83    }
84
85    /// The entity id.
86    pub fn id(&self) -> EntityId {
87        self.slots.id()
88    }
89
90    /// The `LocalOrigin` reference. Required by the schema.
91    pub fn local_origin_ref(&self) -> GeometryResult<EntityId> {
92        self.slots.req_ref(slot::LOCAL_ORIGIN, "LocalOrigin")
93    }
94
95    /// The translation part, promoted to 3D.
96    pub fn local_origin(&self, model: &'m Model) -> GeometryResult<[f64; 3]> {
97        cartesian_point_3d(model, self.id(), self.local_origin_ref()?)
98    }
99
100    /// The raw `Scale`, `None` when the file omitted it.
101    ///
102    /// Prefer [`Self::scale`] unless you specifically need to know whether the
103    /// value was written, e.g. to decide what `Scale2` defaults to.
104    pub fn scale_attribute(&self) -> Option<f64> {
105        self.slots.opt_f64(slot::SCALE)
106    }
107
108    /// The effective uniform scale: the derived `Scl := NVL(Scale, 1.0)`.
109    ///
110    /// A non-positive scale is [`crate::GeometryError::Degenerate`]: the schema's
111    /// `ScaleGreaterZero` rule forbids it, zero collapses the mapped geometry
112    /// to a point, and a negative value mirrors it while leaving the winding
113    /// order inverted, which shows up much later as inside-out normals.
114    pub fn scale(&self) -> GeometryResult<f64> {
115        let value = self.scale_attribute().unwrap_or(1.0);
116        self.checked_scale(value, "Scale")
117    }
118
119    /// The local X direction (`Axis1`), normalized; `None` when absent.
120    pub fn axis1(&self, model: &'m Model) -> GeometryResult<Option<[f64; 3]>> {
121        self.optional_direction(model, slot::AXIS1)
122    }
123
124    /// The local Y direction (`Axis2`), normalized; `None` when absent.
125    ///
126    /// Only its *sign* relative to `Axis3 x Axis1` survives: the derived `U`
127    /// re-orthogonalizes it, so a slightly-off `Axis2` is corrected while an
128    /// opposing one flips the handedness of the frame.
129    pub fn axis2(&self, model: &'m Model) -> GeometryResult<Option<[f64; 3]>> {
130        self.optional_direction(model, slot::AXIS2)
131    }
132
133    /// Read an optional direction slot, normalizing when present.
134    fn optional_direction(
135        &self,
136        model: &'m Model,
137        index: usize,
138    ) -> GeometryResult<Option<[f64; 3]>> {
139        match self.slots.opt_ref(index) {
140            Some(id) => resolve_unit(model, self.id(), id).map(Some),
141            None => Ok(None),
142        }
143    }
144
145    /// Reject a scale the schema forbids, naming which attribute it came from.
146    fn checked_scale(&self, value: f64, attribute: &str) -> GeometryResult<f64> {
147        if value > 0.0 {
148            Ok(value)
149        } else {
150            Err(self.slots.degenerate(format!(
151                "{attribute} is {value}, but the schema requires a scale greater than zero"
152            )))
153        }
154    }
155}
156
157/// A borrowed view of an `IfcCartesianTransformationOperator2D`.
158#[derive(Debug, Clone, Copy)]
159pub struct CartesianTransformationOperator2D<'m> {
160    base: CartesianTransformationOperator<'m>,
161}
162
163impl<'m> CartesianTransformationOperator2D<'m> {
164    /// Wrap an entity assumed to be an `IfcCartesianTransformationOperator2D`.
165    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
166        Self {
167            base: CartesianTransformationOperator::new(id, entity),
168        }
169    }
170
171    /// The shared supertype attributes.
172    pub fn base(&self) -> CartesianTransformationOperator<'m> {
173        self.base
174    }
175
176    /// The entity id.
177    pub fn id(&self) -> EntityId {
178        self.base.id()
179    }
180
181    /// The operator as a 3D transform with uniform scale applied.
182    pub fn transform(&self, model: &'m Model) -> GeometryResult<Transform> {
183        let scale = self.base.scale()?;
184        self.scaled_transform(model, [scale, scale])
185    }
186
187    /// Build the frame and apply per-axis factors.
188    ///
189    /// Z is left unscaled on purpose: a 2D operator has no third axis, so
190    /// scaling Z would be inventing behaviour the file never asked for.
191    fn scaled_transform(&self, model: &'m Model, factors: [f64; 2]) -> GeometryResult<Transform> {
192        let origin = self.base.local_origin(model)?;
193        let axis1 = self.base.axis1(model)?;
194        let axis2 = self.base.axis2(model)?;
195        let frame = base_axes_2d(origin, axis1, axis2).ok_or_else(|| {
196            self.base
197                .slots
198                .degenerate("Axis1 and Axis2 do not define a 2D frame")
199        })?;
200        Ok(frame.scaled_nonuniform([factors[0], factors[1], 1.0]))
201    }
202}
203
204/// A borrowed view of an `IfcCartesianTransformationOperator2DnonUniform`.
205#[derive(Debug, Clone, Copy)]
206pub struct CartesianTransformationOperator2DnonUniform<'m> {
207    inner: CartesianTransformationOperator2D<'m>,
208}
209
210impl<'m> CartesianTransformationOperator2DnonUniform<'m> {
211    /// Wrap an entity assumed to be the 2D non-uniform operator.
212    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
213        Self {
214            inner: CartesianTransformationOperator2D::new(id, entity),
215        }
216    }
217
218    /// The entity id.
219    pub fn id(&self) -> EntityId {
220        self.inner.id()
221    }
222
223    /// The shared supertype attributes.
224    pub fn base(&self) -> CartesianTransformationOperator<'m> {
225        self.inner.base()
226    }
227
228    /// The Y-axis scale: `Scl2 := NVL(Scale2, Scl)`.
229    ///
230    /// Defaults to `Scale`, not to 1.0.
231    pub fn scale2(&self) -> GeometryResult<f64> {
232        let base = self.base();
233        let value = base.slots.opt_f64(slot::SCALE2_2D).unwrap_or(base.scale()?);
234        base.checked_scale(value, "Scale2")
235    }
236
237    /// The operator as a 3D transform with per-axis scale applied.
238    pub fn transform(&self, model: &'m Model) -> GeometryResult<Transform> {
239        let factors = [self.base().scale()?, self.scale2()?];
240        self.inner.scaled_transform(model, factors)
241    }
242}
243
244/// A borrowed view of an `IfcCartesianTransformationOperator3D`.
245#[derive(Debug, Clone, Copy)]
246pub struct CartesianTransformationOperator3D<'m> {
247    base: CartesianTransformationOperator<'m>,
248}
249
250impl<'m> CartesianTransformationOperator3D<'m> {
251    /// Wrap an entity assumed to be an `IfcCartesianTransformationOperator3D`.
252    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
253        Self {
254            base: CartesianTransformationOperator::new(id, entity),
255        }
256    }
257
258    /// The shared supertype attributes.
259    pub fn base(&self) -> CartesianTransformationOperator<'m> {
260        self.base
261    }
262
263    /// The entity id.
264    pub fn id(&self) -> EntityId {
265        self.base.id()
266    }
267
268    /// The local Z direction (`Axis3`), normalized; `None` when absent.
269    pub fn axis3(&self, model: &'m Model) -> GeometryResult<Option<[f64; 3]>> {
270        self.base.optional_direction(model, slot::AXIS3)
271    }
272
273    /// The operator as a transform with uniform scale applied.
274    pub fn transform(&self, model: &'m Model) -> GeometryResult<Transform> {
275        let scale = self.base.scale()?;
276        self.scaled_transform(model, [scale; 3])
277    }
278
279    /// Build the frame and apply per-axis factors.
280    fn scaled_transform(&self, model: &'m Model, factors: [f64; 3]) -> GeometryResult<Transform> {
281        let origin = self.base.local_origin(model)?;
282        let axis1 = self.base.axis1(model)?;
283        let axis2 = self.base.axis2(model)?;
284        let axis3 = self.axis3(model)?;
285        let frame = base_axes_3d(origin, axis1, axis2, axis3).ok_or_else(|| {
286            self.base
287                .slots
288                .degenerate("Axis1 and Axis3 are parallel, so they define no frame")
289        })?;
290        Ok(frame.scaled_nonuniform(factors))
291    }
292}
293
294/// A borrowed view of an `IfcCartesianTransformationOperator3DnonUniform`.
295#[derive(Debug, Clone, Copy)]
296pub struct CartesianTransformationOperator3DnonUniform<'m> {
297    inner: CartesianTransformationOperator3D<'m>,
298}
299
300impl<'m> CartesianTransformationOperator3DnonUniform<'m> {
301    /// Wrap an entity assumed to be the 3D non-uniform operator.
302    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
303        Self {
304            inner: CartesianTransformationOperator3D::new(id, entity),
305        }
306    }
307
308    /// The entity id.
309    pub fn id(&self) -> EntityId {
310        self.inner.id()
311    }
312
313    /// The shared supertype attributes.
314    pub fn base(&self) -> CartesianTransformationOperator<'m> {
315        self.inner.base()
316    }
317
318    /// The Y-axis scale: `Scl2 := NVL(Scale2, Scl)`.
319    pub fn scale2(&self) -> GeometryResult<f64> {
320        self.derived_scale(slot::SCALE2_3D, "Scale2")
321    }
322
323    /// The Z-axis scale: `Scl3 := NVL(Scale3, Scl)`.
324    pub fn scale3(&self) -> GeometryResult<f64> {
325        self.derived_scale(slot::SCALE3_3D, "Scale3")
326    }
327
328    /// The operator as a transform with per-axis scale applied.
329    pub fn transform(&self, model: &'m Model) -> GeometryResult<Transform> {
330        let factors = [self.base().scale()?, self.scale2()?, self.scale3()?];
331        self.inner.scaled_transform(model, factors)
332    }
333
334    /// A secondary scale, falling back to `Scl` rather than to 1.0.
335    fn derived_scale(&self, index: usize, attribute: &str) -> GeometryResult<f64> {
336        let base = self.base();
337        let value = base.slots.opt_f64(index).unwrap_or(base.scale()?);
338        base.checked_scale(value, attribute)
339    }
340}
341
342/// Resolve any `IfcCartesianTransformationOperator` subtype to a transform.
343///
344/// The attribute is typed as the supertype, so a slot holding one may contain
345/// any of the four concrete forms and every consumer would otherwise have to
346/// dispatch. Doing it once here keeps the 2D and non-uniform cases from being
347/// quietly mishandled at each call site, mirroring
348/// [`crate::resource::placement::axis_placement_transform`].
349pub fn operator_transform(
350    model: &Model,
351    id: EntityId,
352    entity: &Entity,
353) -> GeometryResult<Transform> {
354    match entity.type_name.to_ascii_uppercase().as_str() {
355        "IFCCARTESIANTRANSFORMATIONOPERATOR3D" => {
356            CartesianTransformationOperator3D::new(id, entity).transform(model)
357        }
358        "IFCCARTESIANTRANSFORMATIONOPERATOR3DNONUNIFORM" => {
359            CartesianTransformationOperator3DnonUniform::new(id, entity).transform(model)
360        }
361        "IFCCARTESIANTRANSFORMATIONOPERATOR2D" => {
362            CartesianTransformationOperator2D::new(id, entity).transform(model)
363        }
364        "IFCCARTESIANTRANSFORMATIONOPERATOR2DNONUNIFORM" => {
365            CartesianTransformationOperator2DnonUniform::new(id, entity).transform(model)
366        }
367        other => Err(GeometryError::WrongEntityType {
368            entity: id,
369            actual: other.to_string(),
370            expected: "IfcCartesianTransformationOperator",
371        }),
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::error::GeometryError;
379    use ifc_model::Value;
380
381    fn coords(values: &[f64]) -> Value {
382        Value::List(values.iter().copied().map(Value::Real).collect())
383    }
384
385    /// #1 origin at (1,2,3), #2 = global X, #3 = global Y, #4 = global Z.
386    fn model() -> Model {
387        let mut model = Model::new();
388        model.insert(
389            EntityId(1),
390            Entity::new("IFCCARTESIANPOINT", vec![coords(&[1.0, 2.0, 3.0])]),
391        );
392        model.insert(
393            EntityId(2),
394            Entity::new("IFCDIRECTION", vec![coords(&[1.0, 0.0, 0.0])]),
395        );
396        model.insert(
397            EntityId(3),
398            Entity::new("IFCDIRECTION", vec![coords(&[0.0, 1.0, 0.0])]),
399        );
400        model.insert(
401            EntityId(4),
402            Entity::new("IFCDIRECTION", vec![coords(&[0.0, 0.0, 1.0])]),
403        );
404        model
405    }
406
407    /// `IFCCARTESIANTRANSFORMATIONOPERATOR3D(Axis1, Axis2, LocalOrigin, Scale,
408    /// Axis3)` with every axis omitted unless given.
409    fn operator_3d(scale: Value) -> Entity {
410        Entity::new(
411            "IFCCARTESIANTRANSFORMATIONOPERATOR3D",
412            vec![
413                Value::Null,
414                Value::Null,
415                Value::Ref(EntityId(1)),
416                scale,
417                Value::Null,
418            ],
419        )
420    }
421
422    fn close(a: [f64; 3], b: [f64; 3]) -> bool {
423        a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)
424    }
425
426    /// `Scl := NVL(Scale, 1.0)` -- an omitted scale is 1, never 0.
427    #[test]
428    fn absent_scale_defaults_to_one_not_zero() {
429        let e = operator_3d(Value::Null);
430        let op = CartesianTransformationOperator::new(EntityId(9), &e);
431        assert_eq!(op.scale_attribute(), None);
432        assert_eq!(op.scale().unwrap(), 1.0);
433    }
434
435    /// `LocalOrigin` sits at slot 2, after the two optional axes; reading it
436    /// from slot 0 would silently place every mapped item at a direction.
437    #[test]
438    fn local_origin_is_read_after_the_two_optional_axes() {
439        let model = model();
440        let e = operator_3d(Value::Null);
441        let op = CartesianTransformationOperator::new(EntityId(9), &e);
442        assert_eq!(op.local_origin(&model).unwrap(), [1.0, 2.0, 3.0]);
443    }
444
445    #[test]
446    fn a_uniform_operator_scales_every_axis_by_scale() {
447        let model = model();
448        let e = operator_3d(Value::Real(2.0));
449        let t = CartesianTransformationOperator3D::new(EntityId(9), &e)
450            .transform(&model)
451            .unwrap();
452        assert!(close(t.basis[0], [2.0, 0.0, 0.0]));
453        assert!(close(t.basis[1], [0.0, 2.0, 0.0]));
454        assert!(close(t.basis[2], [0.0, 0.0, 2.0]));
455        assert_eq!(t.origin, [1.0, 2.0, 3.0]);
456    }
457
458    /// Axis1 is the operator's X (unlike a placement's Axis, which is Z).
459    #[test]
460    fn axis1_is_the_local_x_and_axis3_the_local_z() {
461        let model = model();
462        let e = Entity::new(
463            "IFCCARTESIANTRANSFORMATIONOPERATOR3D",
464            vec![
465                Value::Ref(EntityId(3)), // Axis1 = global Y
466                Value::Null,
467                Value::Ref(EntityId(1)),
468                Value::Null,
469                Value::Ref(EntityId(4)), // Axis3 = global Z
470            ],
471        );
472        let t = CartesianTransformationOperator3D::new(EntityId(9), &e)
473            .transform(&model)
474            .unwrap();
475        assert!(close(t.basis[0], [0.0, 1.0, 0.0]), "got {:?}", t.basis[0]);
476        assert!(close(t.basis[2], [0.0, 0.0, 1.0]));
477    }
478
479    /// `Scl2 := NVL(Scale2, Scl)`. Defaulting to 1.0 instead would squash
480    /// every instance of the mapped item along Y and Z.
481    #[test]
482    fn nonuniform_secondary_scales_default_to_scale_not_to_one() {
483        let model = model();
484        let e = Entity::new(
485            "IFCCARTESIANTRANSFORMATIONOPERATOR3DNONUNIFORM",
486            vec![
487                Value::Null,
488                Value::Null,
489                Value::Ref(EntityId(1)),
490                Value::Real(3.0),
491                Value::Null,
492                Value::Null, // Scale2 omitted
493                Value::Null, // Scale3 omitted
494            ],
495        );
496        let op = CartesianTransformationOperator3DnonUniform::new(EntityId(9), &e);
497        assert_eq!(op.scale2().unwrap(), 3.0);
498        assert_eq!(op.scale3().unwrap(), 3.0);
499        let t = op.transform(&model).unwrap();
500        assert!(close(t.basis[1], [0.0, 3.0, 0.0]));
501        assert!(close(t.basis[2], [0.0, 0.0, 3.0]));
502    }
503
504    #[test]
505    fn nonuniform_scales_are_applied_per_axis_when_given() {
506        let model = model();
507        let e = Entity::new(
508            "IFCCARTESIANTRANSFORMATIONOPERATOR3DNONUNIFORM",
509            vec![
510                Value::Null,
511                Value::Null,
512                Value::Ref(EntityId(1)),
513                Value::Real(2.0),
514                Value::Null,
515                Value::Real(5.0),
516                Value::Real(7.0),
517            ],
518        );
519        let t = CartesianTransformationOperator3DnonUniform::new(EntityId(9), &e)
520            .transform(&model)
521            .unwrap();
522        assert!(close(t.basis[0], [2.0, 0.0, 0.0]));
523        assert!(close(t.basis[1], [0.0, 5.0, 0.0]));
524        assert!(close(t.basis[2], [0.0, 0.0, 7.0]));
525    }
526
527    /// The 2D and 3D non-uniform variants put `Scale2` at different absolute
528    /// slots (4 and 5), because only the 3D branch inherits `Axis3`.
529    #[test]
530    fn two_d_nonuniform_reads_scale2_one_slot_earlier_than_the_three_d_one() {
531        let model = model();
532        let e = Entity::new(
533            "IFCCARTESIANTRANSFORMATIONOPERATOR2DNONUNIFORM",
534            vec![
535                Value::Null,
536                Value::Null,
537                Value::Ref(EntityId(1)),
538                Value::Real(2.0),
539                Value::Real(6.0), // Scale2 at slot 4, no Axis3 in this branch
540            ],
541        );
542        let op = CartesianTransformationOperator2DnonUniform::new(EntityId(9), &e);
543        assert_eq!(op.scale2().unwrap(), 6.0);
544        let t = op.transform(&model).unwrap();
545        assert!(close(t.basis[0], [2.0, 0.0, 0.0]));
546        assert!(close(t.basis[1], [0.0, 6.0, 0.0]));
547    }
548
549    /// A 2D operator has no third axis, so Z must not be scaled.
550    #[test]
551    fn two_d_operator_leaves_the_z_axis_unscaled() {
552        let model = model();
553        let e = Entity::new(
554            "IFCCARTESIANTRANSFORMATIONOPERATOR2D",
555            vec![
556                Value::Null,
557                Value::Null,
558                Value::Ref(EntityId(1)),
559                Value::Real(4.0),
560            ],
561        );
562        let t = CartesianTransformationOperator2D::new(EntityId(9), &e)
563            .transform(&model)
564            .unwrap();
565        assert!(close(t.basis[0], [4.0, 0.0, 0.0]));
566        assert!(close(t.basis[1], [0.0, 4.0, 0.0]));
567        assert!(close(t.basis[2], [0.0, 0.0, 1.0]), "got {:?}", t.basis[2]);
568    }
569
570    /// `IfcOrthogonalComplement`: Y is X turned a quarter turn counter-
571    /// clockwise when the file does not say otherwise.
572    #[test]
573    fn two_d_y_axis_is_the_orthogonal_complement_of_axis1() {
574        let mut model = model();
575        model.insert(
576            EntityId(5),
577            Entity::new("IFCDIRECTION", vec![coords(&[0.0, 1.0])]),
578        );
579        let e = Entity::new(
580            "IFCCARTESIANTRANSFORMATIONOPERATOR2D",
581            vec![
582                Value::Ref(EntityId(5)),
583                Value::Null,
584                Value::Ref(EntityId(1)),
585                Value::Null,
586            ],
587        );
588        let t = CartesianTransformationOperator2D::new(EntityId(9), &e)
589            .transform(&model)
590            .unwrap();
591        assert!(close(t.basis[0], [0.0, 1.0, 0.0]));
592        assert!(close(t.basis[1], [-1.0, 0.0, 0.0]), "got {:?}", t.basis[1]);
593    }
594
595    /// An Axis2 opposing the derived Y flips it -- that is how a mirrored
596    /// mapped item is written, so dropping the check loses the mirroring.
597    #[test]
598    fn an_opposing_axis2_flips_the_derived_y_axis() {
599        let mut model = model();
600        model.insert(
601            EntityId(6),
602            Entity::new("IFCDIRECTION", vec![coords(&[0.0, -1.0])]),
603        );
604        model.insert(
605            EntityId(7),
606            Entity::new("IFCDIRECTION", vec![coords(&[1.0, 0.0])]),
607        );
608        let e = Entity::new(
609            "IFCCARTESIANTRANSFORMATIONOPERATOR2D",
610            vec![
611                Value::Ref(EntityId(7)), // Axis1 = X
612                Value::Ref(EntityId(6)), // Axis2 = -Y
613                Value::Ref(EntityId(1)),
614                Value::Null,
615            ],
616        );
617        let t = CartesianTransformationOperator2D::new(EntityId(9), &e)
618            .transform(&model)
619            .unwrap();
620        assert!(close(t.basis[1], [0.0, -1.0, 0.0]), "got {:?}", t.basis[1]);
621    }
622
623    /// Same in 3D: Axis2 only decides handedness, since the derived Y is
624    /// re-orthogonalized regardless.
625    #[test]
626    fn an_opposing_axis2_flips_handedness_in_three_d_too() {
627        let mut model = model();
628        model.insert(
629            EntityId(8),
630            Entity::new("IFCDIRECTION", vec![coords(&[0.0, -1.0, 0.0])]),
631        );
632        let e = Entity::new(
633            "IFCCARTESIANTRANSFORMATIONOPERATOR3D",
634            vec![
635                Value::Ref(EntityId(2)),
636                Value::Ref(EntityId(8)),
637                Value::Ref(EntityId(1)),
638                Value::Null,
639                Value::Ref(EntityId(4)),
640            ],
641        );
642        let t = CartesianTransformationOperator3D::new(EntityId(9), &e)
643            .transform(&model)
644            .unwrap();
645        assert!(close(t.basis[1], [0.0, -1.0, 0.0]), "got {:?}", t.basis[1]);
646    }
647
648    /// `ScaleGreaterZero`: zero collapses the geometry, so it is a hard error
649    /// rather than a transform that quietly erases every mapped item.
650    #[test]
651    fn zero_scale_is_degenerate() {
652        let e = operator_3d(Value::Real(0.0));
653        let err = CartesianTransformationOperator::new(EntityId(9), &e)
654            .scale()
655            .unwrap_err();
656        assert!(matches!(err, GeometryError::Degenerate { .. }), "{err}");
657    }
658
659    #[test]
660    fn negative_scale_is_degenerate() {
661        let e = operator_3d(Value::Real(-1.0));
662        assert!(CartesianTransformationOperator::new(EntityId(9), &e)
663            .scale()
664            .is_err());
665    }
666
667    #[test]
668    fn a_negative_secondary_scale_names_that_attribute() {
669        let e = Entity::new(
670            "IFCCARTESIANTRANSFORMATIONOPERATOR3DNONUNIFORM",
671            vec![
672                Value::Null,
673                Value::Null,
674                Value::Ref(EntityId(1)),
675                Value::Real(1.0),
676                Value::Null,
677                Value::Real(-2.0),
678                Value::Null,
679            ],
680        );
681        let err = CartesianTransformationOperator3DnonUniform::new(EntityId(9), &e)
682            .scale2()
683            .unwrap_err();
684        assert!(err.to_string().contains("Scale2"), "got: {err}");
685    }
686
687    /// Zero-length axes would otherwise normalize to NaN and propagate.
688    #[test]
689    fn a_zero_length_axis_is_degenerate_rather_than_nan() {
690        let mut model = model();
691        model.insert(
692            EntityId(9),
693            Entity::new("IFCDIRECTION", vec![coords(&[0.0, 0.0, 0.0])]),
694        );
695        let e = Entity::new(
696            "IFCCARTESIANTRANSFORMATIONOPERATOR3D",
697            vec![
698                Value::Null,
699                Value::Null,
700                Value::Ref(EntityId(1)),
701                Value::Null,
702                Value::Ref(EntityId(9)),
703            ],
704        );
705        let err = CartesianTransformationOperator3D::new(EntityId(20), &e)
706            .transform(&model)
707            .unwrap_err();
708        assert!(matches!(err, GeometryError::Degenerate { .. }), "{err}");
709    }
710
711    /// Axis1 parallel to Axis3 leaves no plane to project X into.
712    #[test]
713    fn axis1_parallel_to_axis3_is_degenerate() {
714        let model = model();
715        let e = Entity::new(
716            "IFCCARTESIANTRANSFORMATIONOPERATOR3D",
717            vec![
718                Value::Ref(EntityId(4)),
719                Value::Null,
720                Value::Ref(EntityId(1)),
721                Value::Null,
722                Value::Ref(EntityId(4)),
723            ],
724        );
725        assert!(CartesianTransformationOperator3D::new(EntityId(20), &e)
726            .transform(&model)
727            .is_err());
728    }
729
730    #[test]
731    fn a_missing_local_origin_names_the_entity_and_attribute() {
732        let e = Entity::new("IFCCARTESIANTRANSFORMATIONOPERATOR3D", vec![]);
733        let err = CartesianTransformationOperator::new(EntityId(42), &e)
734            .local_origin_ref()
735            .unwrap_err();
736        assert!(err.to_string().contains("#42"), "got: {err}");
737        assert!(err.to_string().contains("LocalOrigin"), "got: {err}");
738    }
739
740    /// With no axes at all the frame is the identity translated to LocalOrigin.
741    #[test]
742    fn an_operator_without_axes_is_a_pure_translation_and_scale() {
743        let model = model();
744        let e = operator_3d(Value::Null);
745        let t = CartesianTransformationOperator3D::new(EntityId(9), &e)
746            .transform(&model)
747            .unwrap();
748        assert_eq!(t, Transform::translation([1.0, 2.0, 3.0]));
749    }
750
751    /// Axis2 alone must still produce a right-handed 2D frame.
752    #[test]
753    fn a_two_d_operator_with_only_axis2_derives_x_from_it() {
754        let mut model = model();
755        model.insert(
756            EntityId(5),
757            Entity::new("IFCDIRECTION", vec![coords(&[0.0, 1.0])]),
758        );
759        let e = Entity::new(
760            "IFCCARTESIANTRANSFORMATIONOPERATOR2D",
761            vec![
762                Value::Null,
763                Value::Ref(EntityId(5)),
764                Value::Ref(EntityId(1)),
765                Value::Null,
766            ],
767        );
768        let t = CartesianTransformationOperator2D::new(EntityId(9), &e)
769            .transform(&model)
770            .unwrap();
771        assert!(close(t.basis[0], [1.0, 0.0, 0.0]), "got {:?}", t.basis[0]);
772        assert!(close(t.basis[1], [0.0, 1.0, 0.0]), "got {:?}", t.basis[1]);
773    }
774}