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