1#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct Transform {
22 pub basis: [[f64; 3]; 3],
24 pub origin: [f64; 3],
26}
27
28impl Default for Transform {
29 fn default() -> Self {
30 Self::identity()
31 }
32}
33
34impl Transform {
35 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 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 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 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 let y = cross(z, x);
84
85 Some(Self {
86 basis: [x, y, z],
87 origin,
88 })
89 }
90
91 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 pub fn determinant(&self) -> f64 {
119 dot(self.basis[0], cross(self.basis[1], self.basis[2]))
120 }
121
122 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 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 #[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 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 #[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 #[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 #[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 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 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 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 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 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
389fn 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
417fn 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 #[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]), )
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 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 #[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 #[test]
504 fn composition_applies_rotation_to_the_child_offset() {
505 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}