Skip to main content

ifc_lite_geometry/
transform.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Shared transform utilities for IFC geometry processing
6//!
7//! Provides unified implementations for parsing IFC placement and direction entities,
8//! eliminating code duplication across processors.
9
10use crate::error::{Error, Result};
11use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
12use nalgebra::{Matrix4, Point3, Vector3};
13
14/// Parse IfcAxis2Placement3D into transformation matrix
15///
16/// IfcAxis2Placement3D attributes:
17/// - 0: Location (IfcCartesianPoint)
18/// - 1: Axis (IfcDirection, optional)
19/// - 2: RefDirection (IfcDirection, optional)
20///
21/// Returns a 4x4 transformation matrix that transforms from local coordinates
22/// to parent coordinates.
23pub fn parse_axis2_placement_3d(
24    placement: &DecodedEntity,
25    decoder: &mut EntityDecoder,
26) -> Result<Matrix4<f64>> {
27    // Get location (attribute 0)
28    let location = parse_cartesian_point(placement, decoder, 0)?;
29
30    // Get Z axis (attribute 1) - defaults to (0, 0, 1)
31    let z_axis = if let Some(axis_attr) = placement.get(1) {
32        if !axis_attr.is_null() {
33            if let Some(axis_entity) = decoder.resolve_ref(axis_attr)? {
34                parse_direction(&axis_entity)?
35            } else {
36                Vector3::new(0.0, 0.0, 1.0)
37            }
38        } else {
39            Vector3::new(0.0, 0.0, 1.0)
40        }
41    } else {
42        Vector3::new(0.0, 0.0, 1.0)
43    };
44
45    // Get X axis (attribute 2: RefDirection) - defaults to (1, 0, 0)
46    let x_axis = if let Some(ref_dir_attr) = placement.get(2) {
47        if !ref_dir_attr.is_null() {
48            if let Some(ref_dir_entity) = decoder.resolve_ref(ref_dir_attr)? {
49                parse_direction(&ref_dir_entity)?
50            } else {
51                Vector3::new(1.0, 0.0, 0.0)
52            }
53        } else {
54            Vector3::new(1.0, 0.0, 0.0)
55        }
56    } else {
57        Vector3::new(1.0, 0.0, 0.0)
58    };
59
60    Ok(build_axis2_matrix(location, z_axis, x_axis))
61}
62
63/// Orthonormalize a placement's raw axes + location into a column-major 4×4
64/// transform (columns = world-space local X, Y, Z, then translation).
65///
66/// `z_axis` is the raw Axis (local +Z), `x_axis` the raw RefDirection (local
67/// +X); both are normalized here. This is the single home for the
68/// degenerate-axis fallback: when RefDirection is parallel to Axis the projected
69/// X collapses, so instead of normalizing a zero vector (which yields a NaN
70/// matrix) we pick a deterministic perpendicular direction. The math is the
71/// canonical Gram–Schmidt (normalize Z, project RefDirection onto the plane ⟂ Z,
72/// then Y = Z × X). Every `IfcAxis2Placement3D` parser in the crate keeps its
73/// own attribute extraction / default-axis choices and shares only this
74/// orthonormalization + assembly, so the guard can never drift out of a fork.
75pub(crate) fn build_axis2_matrix(
76    location: Point3<f64>,
77    z_axis: Vector3<f64>,
78    x_axis: Vector3<f64>,
79) -> Matrix4<f64> {
80    // Normalize axes. A malformed IfcDirection((0,0,0)) as Axis/RefDirection would
81    // make a bare normalize() emit NaN (0/0) and poison the whole placement matrix
82    // (this helper is the shared home for every IfcAxis2Placement3D parser, so the
83    // guard lives here once). A zero Axis falls back to +Z; a zero RefDirection
84    // routes into the parallel-axis fallback below.
85    let z_axis_final = z_axis
86        .try_normalize(1e-9)
87        .unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0));
88    let x_axis_normalized = x_axis
89        .try_normalize(1e-9)
90        .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0));
91
92    // Ensure X is orthogonal to Z (project X onto plane perpendicular to Z)
93    let dot_product = x_axis_normalized.dot(&z_axis_final);
94    let x_axis_orthogonal = x_axis_normalized - z_axis_final * dot_product;
95    let x_axis_final = if x_axis_orthogonal.norm() > 1e-6 {
96        x_axis_orthogonal.normalize()
97    } else {
98        // X and Z are parallel or nearly parallel - use a default perpendicular direction
99        if z_axis_final.z.abs() < 0.9 {
100            Vector3::new(0.0, 0.0, 1.0).cross(&z_axis_final).normalize()
101        } else {
102            Vector3::new(1.0, 0.0, 0.0).cross(&z_axis_final).normalize()
103        }
104    };
105
106    // Y axis is cross product of Z and X (right-hand rule: Y = Z × X)
107    let y_axis = z_axis_final.cross(&x_axis_final).normalize();
108
109    // Build transformation matrix
110    // Columns represent world-space directions of local axes
111    let mut transform = Matrix4::identity();
112    transform[(0, 0)] = x_axis_final.x;
113    transform[(1, 0)] = x_axis_final.y;
114    transform[(2, 0)] = x_axis_final.z;
115    transform[(0, 1)] = y_axis.x;
116    transform[(1, 1)] = y_axis.y;
117    transform[(2, 1)] = y_axis.z;
118    transform[(0, 2)] = z_axis_final.x;
119    transform[(1, 2)] = z_axis_final.y;
120    transform[(2, 2)] = z_axis_final.z;
121    transform[(0, 3)] = location.x;
122    transform[(1, 3)] = location.y;
123    transform[(2, 3)] = location.z;
124
125    transform
126}
127
128/// Parse IfcCartesianPoint from an entity attribute
129///
130/// Attempts fast-path extraction first, falls back to full decode if needed.
131pub fn parse_cartesian_point(
132    parent: &DecodedEntity,
133    decoder: &mut EntityDecoder,
134    attr_index: usize,
135) -> Result<Point3<f64>> {
136    let point_attr = parent
137        .get(attr_index)
138        .ok_or_else(|| Error::geometry("Missing cartesian point".to_string()))?;
139
140    // Try fast path first
141    if let Some(point_id) = point_attr.as_entity_ref() {
142        if let Some((x, y, z)) = decoder.get_cartesian_point_fast(point_id) {
143            return Ok(Point3::new(x, y, z));
144        }
145    }
146
147    // Fallback to full decode
148    let point_entity = decoder
149        .resolve_ref(point_attr)?
150        .ok_or_else(|| Error::geometry("Failed to resolve cartesian point".to_string()))?;
151
152    if point_entity.ifc_type != IfcType::IfcCartesianPoint {
153        return Err(Error::geometry(format!(
154            "Expected IfcCartesianPoint, got {}",
155            point_entity.ifc_type
156        )));
157    }
158
159    // Get coordinates list (attribute 0)
160    let coords_attr = point_entity
161        .get(0)
162        .ok_or_else(|| Error::geometry("IfcCartesianPoint missing coordinates".to_string()))?;
163
164    let coords = coords_attr
165        .as_list()
166        .ok_or_else(|| Error::geometry("Expected coordinate list".to_string()))?;
167
168    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
169    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
170    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
171
172    Ok(Point3::new(x, y, z))
173}
174
175/// Parse IfcCartesianPoint from entity ID (fast-path variant)
176///
177/// Uses fast-path extraction when available.
178pub fn parse_cartesian_point_from_id(
179    point_id: u32,
180    decoder: &mut EntityDecoder,
181) -> Result<Point3<f64>> {
182    // Try fast path first
183    if let Some((x, y, z)) = decoder.get_cartesian_point_fast(point_id) {
184        return Ok(Point3::new(x, y, z));
185    }
186
187    // Fallback to full decode
188    let point_entity = decoder.decode_by_id(point_id)?;
189
190    if point_entity.ifc_type != IfcType::IfcCartesianPoint {
191        return Err(Error::geometry(format!(
192            "Expected IfcCartesianPoint, got {}",
193            point_entity.ifc_type
194        )));
195    }
196
197    // Get coordinates list (attribute 0)
198    let coords_attr = point_entity
199        .get(0)
200        .ok_or_else(|| Error::geometry("IfcCartesianPoint missing coordinates".to_string()))?;
201
202    let coords = coords_attr
203        .as_list()
204        .ok_or_else(|| Error::geometry("Expected coordinate list".to_string()))?;
205
206    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
207    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
208    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
209
210    Ok(Point3::new(x, y, z))
211}
212
213/// Parse IfcDirection from entity ID.
214///
215/// Plain decode-by-id + parse; unlike [`parse_cartesian_point_from_id`], there
216/// is no fast-path extraction for `IfcDirection`.
217pub fn parse_direction_from_id(dir_id: u32, decoder: &mut EntityDecoder) -> Result<Vector3<f64>> {
218    let dir = decoder.decode_by_id(dir_id)?;
219    parse_direction(&dir)
220}
221
222/// Parse IfcAxis2Placement3D from entity ID (fast-path variant)
223///
224/// Uses fast-path extraction when available for location and directions.
225pub fn parse_axis2_placement_3d_from_id(
226    placement_id: u32,
227    decoder: &mut EntityDecoder,
228) -> Result<Matrix4<f64>> {
229    let placement = decoder.decode_by_id(placement_id)?;
230
231    // Get location using fast path if available
232    let location = if let Some(loc_attr) = placement.get(0) {
233        if let Some(loc_id) = loc_attr.as_entity_ref() {
234            parse_cartesian_point_from_id(loc_id, decoder)?
235        } else {
236            Point3::new(0.0, 0.0, 0.0)
237        }
238    } else {
239        Point3::new(0.0, 0.0, 0.0)
240    };
241
242    // Get Z axis (attribute 1)
243    let z_axis = if let Some(axis_attr) = placement.get(1) {
244        if !axis_attr.is_null() {
245            if let Some(axis_id) = axis_attr.as_entity_ref() {
246                parse_direction_from_id(axis_id, decoder)?
247            } else {
248                Vector3::new(0.0, 0.0, 1.0)
249            }
250        } else {
251            Vector3::new(0.0, 0.0, 1.0)
252        }
253    } else {
254        Vector3::new(0.0, 0.0, 1.0)
255    };
256
257    // Get X axis (attribute 2: RefDirection)
258    let x_axis = if let Some(ref_dir_attr) = placement.get(2) {
259        if !ref_dir_attr.is_null() {
260            if let Some(ref_dir_id) = ref_dir_attr.as_entity_ref() {
261                parse_direction_from_id(ref_dir_id, decoder)?
262            } else {
263                Vector3::new(1.0, 0.0, 0.0)
264            }
265        } else {
266            Vector3::new(1.0, 0.0, 0.0)
267        }
268    } else {
269        Vector3::new(1.0, 0.0, 0.0)
270    };
271
272    Ok(build_axis2_matrix(location, z_axis, x_axis))
273}
274
275/// Parse IfcDirection entity
276///
277/// Extracts direction ratios from IfcDirection (attribute 0).
278pub fn parse_direction(direction_entity: &DecodedEntity) -> Result<Vector3<f64>> {
279    if direction_entity.ifc_type != IfcType::IfcDirection {
280        return Err(Error::geometry(format!(
281            "Expected IfcDirection, got {}",
282            direction_entity.ifc_type
283        )));
284    }
285
286    // Get direction ratios (attribute 0)
287    let ratios_attr = direction_entity
288        .get(0)
289        .ok_or_else(|| Error::geometry("IfcDirection missing ratios".to_string()))?;
290
291    let ratios = ratios_attr
292        .as_list()
293        .ok_or_else(|| Error::geometry("Expected ratio list".to_string()))?;
294
295    let x = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
296    let y = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
297    let z = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
298
299    Ok(Vector3::new(x, y, z))
300}
301
302/// The building/site rotation about the world vertical (Z) axis, derived from a
303/// resolved **column-major** 4×4 placement matrix (as returned by
304/// [`crate::GeometryRouter::resolve_scaled_placement`]). The local X-axis (the
305/// placement's RefDirection, composed through the full parent chain and
306/// normalized) is column 0 — elements `[0]`, `[1]`, `[2]` — so its angle in the
307/// world XY plane is `atan2(m[1], m[0])`.
308///
309/// This is the single source of truth for site rotation: the processor consumes
310/// the full matrix (baking the inverse rotation into vertices for the
311/// `site_local` frame) while the viewer takes this angle for its render-frame
312/// rotation — both off the *same* resolved matrix, so they cannot drift on
313/// nested, scaled, or tilted-axis placements (where `atan2` of the raw
314/// top-level RefDirection is incomplete). Returns `None` for a degenerate
315/// (zero-length) projected X-axis.
316pub fn rotation_angle_about_z(matrix: &[f64; 16]) -> Option<f64> {
317    let x = matrix[0];
318    let y = matrix[1];
319    if x * x + y * y < 1e-10 {
320        return None;
321    }
322    Some(y.atan2(x))
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_parse_direction() {
331        // parse_direction reads the ratio list from attribute 0.
332        let content = "#1=IFCDIRECTION((0.5,0.25,0.75));";
333        let mut decoder = EntityDecoder::new(content);
334        let dir = decoder.decode_by_id(1).unwrap();
335
336        let v = parse_direction(&dir).unwrap();
337
338        assert!((v.x - 0.5).abs() < 1e-12);
339        assert!((v.y - 0.25).abs() < 1e-12);
340        assert!((v.z - 0.75).abs() < 1e-12);
341    }
342
343    #[test]
344    fn parse_cartesian_point_reads_coordinates_from_attribute_0() {
345        // parse_cartesian_point(parent, decoder, 0) resolves the ref stored
346        // at attribute 0 and reads its coordinate list.
347        let content = "\
348#1=IFCCARTESIANPOINT((1.0,2.0,3.0));
349#2=IFCAXIS2PLACEMENT3D(#1,$,$);";
350        let mut decoder = EntityDecoder::new(content);
351        let placement = decoder.decode_by_id(2).unwrap();
352
353        let p = parse_cartesian_point(&placement, &mut decoder, 0).unwrap();
354
355        assert!((p.x - 1.0).abs() < 1e-12);
356        assert!((p.y - 2.0).abs() < 1e-12);
357        assert!((p.z - 3.0).abs() < 1e-12);
358    }
359
360    #[test]
361    fn parse_axis2_placement_3d_defaults_missing_axis_and_ref_direction() {
362        // IfcAxis2Placement3D attributes 1 (Axis) and 2 (RefDirection) are
363        // entirely absent (not even `$`), so `placement.get(1)`/`get(2)`
364        // return `None` and the default world Z/X axes must be used with no
365        // orthogonalization needed (they're already perpendicular).
366        let content = "\
367#1=IFCCARTESIANPOINT((10.0,20.0,30.0));
368#2=IFCAXIS2PLACEMENT3D(#1);";
369        let mut decoder = EntityDecoder::new(content);
370        let placement = decoder.decode_by_id(2).unwrap();
371        assert_eq!(placement.attributes.len(), 1, "test fixture sanity check");
372
373        let m = parse_axis2_placement_3d(&placement, &mut decoder).unwrap();
374
375        // Translation column carries the location through unchanged.
376        assert!((m[(0, 3)] - 10.0).abs() < 1e-9);
377        assert!((m[(1, 3)] - 20.0).abs() < 1e-9);
378        assert!((m[(2, 3)] - 30.0).abs() < 1e-9);
379        // Default Z axis (0,0,1) -> column 2.
380        assert!((m[(0, 2)] - 0.0).abs() < 1e-9);
381        assert!((m[(1, 2)] - 0.0).abs() < 1e-9);
382        assert!((m[(2, 2)] - 1.0).abs() < 1e-9);
383        // Default X axis (1,0,0) -> column 0.
384        assert!((m[(0, 0)] - 1.0).abs() < 1e-9);
385        assert!((m[(1, 0)] - 0.0).abs() < 1e-9);
386        assert!((m[(2, 0)] - 0.0).abs() < 1e-9);
387    }
388
389    #[test]
390    fn parse_axis2_placement_3d_defaults_ref_direction_when_only_axis_given() {
391        // Axis is explicitly (0,1,0); RefDirection attribute is missing
392        // (only 2 of 3 attributes present), so it must default to world X
393        // (1,0,0), which is already orthogonal to (0,1,0) here.
394        let content = "\
395#1=IFCCARTESIANPOINT((0.0,0.0,0.0));
396#2=IFCDIRECTION((0.0,1.0,0.0));
397#3=IFCAXIS2PLACEMENT3D(#1,#2);";
398        let mut decoder = EntityDecoder::new(content);
399        let placement = decoder.decode_by_id(3).unwrap();
400        assert_eq!(placement.attributes.len(), 2, "test fixture sanity check");
401
402        let m = parse_axis2_placement_3d(&placement, &mut decoder).unwrap();
403
404        // Z axis is the custom (0,1,0) -> column 2.
405        assert!((m[(0, 2)] - 0.0).abs() < 1e-9);
406        assert!((m[(1, 2)] - 1.0).abs() < 1e-9);
407        assert!((m[(2, 2)] - 0.0).abs() < 1e-9);
408        // X axis defaults to world (1,0,0), already orthogonal -> column 0.
409        assert!((m[(0, 0)] - 1.0).abs() < 1e-9);
410        assert!((m[(1, 0)] - 0.0).abs() < 1e-9);
411        assert!((m[(2, 0)] - 0.0).abs() < 1e-9);
412        // Y = Z x X = (0,1,0) x (1,0,0) = (0,0,-1) -> column 1.
413        assert!((m[(0, 1)] - 0.0).abs() < 1e-9);
414        assert!((m[(1, 1)] - 0.0).abs() < 1e-9);
415        assert!((m[(2, 1)] - (-1.0)).abs() < 1e-9);
416    }
417
418    #[test]
419    fn parse_axis2_placement_3d_orthogonalizes_parallel_ref_direction_low_z() {
420        // RefDirection parallel to Axis forces the fallback branch. With
421        // Axis = (1,0,0), |z.z| = 0 < 0.9, so the fallback is
422        // world-Z x Axis = (0,0,1) x (1,0,0) = (0,1,0).
423        let content = "\
424#1=IFCCARTESIANPOINT((0.0,0.0,0.0));
425#2=IFCDIRECTION((1.0,0.0,0.0));
426#3=IFCDIRECTION((1.0,0.0,0.0));
427#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);";
428        let mut decoder = EntityDecoder::new(content);
429        let placement = decoder.decode_by_id(4).unwrap();
430
431        let m = parse_axis2_placement_3d(&placement, &mut decoder).unwrap();
432
433        // X axis (column 0) is the fallback perpendicular (0,1,0), not the
434        // degenerate parallel RefDirection.
435        assert!((m[(0, 0)] - 0.0).abs() < 1e-9);
436        assert!((m[(1, 0)] - 1.0).abs() < 1e-9);
437        assert!((m[(2, 0)] - 0.0).abs() < 1e-9);
438        // Y = Z x X = (1,0,0) x (0,1,0) = (0,0,1) -> column 1.
439        assert!((m[(0, 1)] - 0.0).abs() < 1e-9);
440        assert!((m[(1, 1)] - 0.0).abs() < 1e-9);
441        assert!((m[(2, 1)] - 1.0).abs() < 1e-9);
442    }
443
444    #[test]
445    fn parse_axis2_placement_3d_orthogonalizes_parallel_ref_direction_high_z() {
446        // RefDirection parallel to Axis forces the fallback branch. With
447        // Axis = (0,0,1), |z.z| = 1 >= 0.9, so the fallback is
448        // world-X x Axis = (1,0,0) x (0,0,1) = (0,-1,0).
449        let content = "\
450#1=IFCCARTESIANPOINT((0.0,0.0,0.0));
451#2=IFCDIRECTION((0.0,0.0,1.0));
452#3=IFCDIRECTION((0.0,0.0,1.0));
453#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);";
454        let mut decoder = EntityDecoder::new(content);
455        let placement = decoder.decode_by_id(4).unwrap();
456
457        let m = parse_axis2_placement_3d(&placement, &mut decoder).unwrap();
458
459        // X axis (column 0) is the fallback perpendicular (0,-1,0).
460        assert!((m[(0, 0)] - 0.0).abs() < 1e-9);
461        assert!((m[(1, 0)] - (-1.0)).abs() < 1e-9);
462        assert!((m[(2, 0)] - 0.0).abs() < 1e-9);
463        // Y = Z x X = (0,0,1) x (0,-1,0) = (1,0,0) -> column 1.
464        assert!((m[(0, 1)] - 1.0).abs() < 1e-9);
465        assert!((m[(1, 1)] - 0.0).abs() < 1e-9);
466        assert!((m[(2, 1)] - 0.0).abs() < 1e-9);
467    }
468
469    #[test]
470    fn rotation_angle_about_z_handles_identity_rotation_and_scale() {
471        // Identity → 0 rad.
472        let mut m = [0.0f64; 16];
473        m[0] = 1.0;
474        m[5] = 1.0;
475        m[10] = 1.0;
476        m[15] = 1.0;
477        assert!(rotation_angle_about_z(&m).unwrap().abs() < 1e-12);
478
479        // 45° about Z: column-0 X-axis = (cos45, sin45, 0). Matches the legacy
480        // atan2(RefDirection.y, RefDirection.x) for an axis-aligned placement.
481        let a = std::f64::consts::FRAC_PI_4;
482        let mut r = [0.0f64; 16];
483        r[0] = a.cos();
484        r[1] = a.sin();
485        r[4] = -a.sin();
486        r[5] = a.cos();
487        r[10] = 1.0;
488        r[15] = 1.0;
489        assert!((rotation_angle_about_z(&r).unwrap() - a).abs() < 1e-12);
490
491        // Uniform scale is angle-invariant (resolve_scaled_placement may carry scale).
492        let mut s = r;
493        for v in s.iter_mut().take(3) {
494            *v *= 3.0;
495        }
496        assert!((rotation_angle_about_z(&s).unwrap() - a).abs() < 1e-9);
497
498        // Degenerate (zero-length) projected X-axis → None.
499        assert!(rotation_angle_about_z(&[0.0f64; 16]).is_none());
500    }
501
502    // A malformed IfcDirection((0,0,0)) as Axis must not NaN-poison the matrix;
503    // build_axis2_matrix is the shared home for every IfcAxis2Placement3D parser.
504    #[test]
505    fn build_axis2_matrix_zero_axis_stays_finite() {
506        let m = build_axis2_matrix(
507            Point3::new(0.0, 0.0, 0.0),
508            Vector3::new(0.0, 0.0, 0.0),
509            Vector3::new(1.0, 0.0, 0.0),
510        );
511        assert!(m.iter().all(|v| v.is_finite()), "NaN in matrix: {m:?}");
512    }
513}