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 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 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 #[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 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 #[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 #[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 #[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 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 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 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 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 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
378fn 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
406fn 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 #[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]), )
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 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 #[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 #[test]
493 fn composition_applies_rotation_to_the_child_offset() {
494 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}