Skip to main content

cadforge_core/
representation.rs

1//! Element geometry, in the form IFC stores it.
2//!
3//! This is the bridge between "the recipe is canonical" (ADR-0004) and "IFC is the exchange
4//! authority" (`docs/ifc-semantics.md` ADR-001). A `GeometryRecipe` is how a family *authors*
5//! geometry; a `Representation` is what that evaluates to and what gets written to a file.
6//!
7//! Deliberately pure data with no dependency on `cadforge-geom` or `cadforge-family`: core
8//! must stay standalone (ADR-0002), and the exporter must not have to understand recipes to
9//! write a file.
10//!
11//! The two variants are not equivalent, and the difference is the whole point of ADR-0004:
12//!
13//! - [`Representation::ExtrudedAreaSolid`] exports as `IfcExtrudedAreaSolid` over
14//!   `IfcArbitraryClosedProfileDef`. It stays **parametric and editable** in Revit, Archicad,
15//!   and Bonsai after a round trip.
16//! - [`Representation::TriangulatedFaceSet`] exports as `IfcTriangulatedFaceSet`. It is
17//!   geometrically correct and semantically poorer — a receiving application can display it
18//!   but not edit it.
19//!
20//! An element that degrades from the first to the second has lost something real, so the
21//! degradation is visible in the type rather than buried in a log.
22
23use serde::{Deserialize, Serialize};
24
25/// The geometry of one element, in its own local coordinate system.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub enum Representation {
28    /// A closed profile swept along a direction.
29    ///
30    /// Covers walls, slabs, columns, beams, openings, ducts, and pipes — the large majority
31    /// of real building elements.
32    ExtrudedAreaSolid {
33        /// The profile in the local XY plane: counter-clockwise, with no repeated closing
34        /// point. Invariants come from `cadforge_geom::Profile`.
35        profile: Vec<[f64; 2]>,
36        /// Sweep direction in local space.
37        direction: [f64; 3],
38        /// Sweep distance. Always positive — a negative depth is normalised into the
39        /// direction when the representation is built, because IFC requires
40        /// `IfcExtrudedAreaSolid.Depth` to be positive.
41        depth: f64,
42    },
43    /// Explicit triangles, for geometry no recipe can express.
44    TriangulatedFaceSet {
45        vertices: Vec<[f64; 3]>,
46        faces: Vec<[u32; 3]>,
47    },
48}
49
50impl Representation {
51    /// Build a swept solid, normalising a negative depth into the direction.
52    pub fn extrusion(profile: Vec<[f64; 2]>, direction: [f64; 3], depth: f64) -> Self {
53        if depth < 0.0 {
54            Self::ExtrudedAreaSolid {
55                profile,
56                direction: [-direction[0], -direction[1], -direction[2]],
57                depth: -depth,
58            }
59        } else {
60            Self::ExtrudedAreaSolid {
61                profile,
62                direction,
63                depth,
64            }
65        }
66    }
67
68    /// Whether this survives export as editable parametric geometry.
69    pub fn is_native_parametric(&self) -> bool {
70        matches!(self, Self::ExtrudedAreaSolid { .. })
71    }
72
73    /// The `IfcShapeRepresentation.RepresentationType` this maps to.
74    pub fn ifc_representation_type(&self) -> &'static str {
75        match self {
76            Self::ExtrudedAreaSolid { .. } => "SweptSolid",
77            Self::TriangulatedFaceSet { .. } => "Tessellation",
78        }
79    }
80
81    /// The IFC entity name of the representation item.
82    pub fn ifc_item(&self) -> &'static str {
83        match self {
84            Self::ExtrudedAreaSolid { .. } => "IfcExtrudedAreaSolid",
85            Self::TriangulatedFaceSet { .. } => "IfcTriangulatedFaceSet",
86        }
87    }
88
89    /// Structural check before export. A malformed representation must never reach a file.
90    pub fn is_valid(&self) -> bool {
91        match self {
92            Self::ExtrudedAreaSolid {
93                profile,
94                direction,
95                depth,
96            } => {
97                profile.len() >= 3
98                    && profile.iter().flatten().all(|v| v.is_finite())
99                    && direction.iter().all(|v| v.is_finite())
100                    && direction.iter().any(|v| *v != 0.0)
101                    && depth.is_finite()
102                    && *depth > 0.0
103            }
104            Self::TriangulatedFaceSet { vertices, faces } => {
105                !vertices.is_empty()
106                    && !faces.is_empty()
107                    && vertices.iter().flatten().all(|v| v.is_finite())
108                    && faces
109                        .iter()
110                        .flatten()
111                        .all(|i| (*i as usize) < vertices.len())
112            }
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn square() -> Vec<[f64; 2]> {
122        vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
123    }
124
125    #[test]
126    fn a_negative_depth_is_folded_into_the_direction() {
127        // IFC requires a positive Depth, so the flip has to happen somewhere. Doing it here
128        // means the exporter never has to think about it.
129        let r = Representation::extrusion(square(), [0.0, 0.0, 1.0], -3.0);
130        let Representation::ExtrudedAreaSolid {
131            direction, depth, ..
132        } = &r
133        else {
134            panic!("expected a swept solid");
135        };
136        assert_eq!(*direction, [0.0, 0.0, -1.0]);
137        assert_eq!(*depth, 3.0);
138        assert!(r.is_valid());
139    }
140
141    #[test]
142    fn a_positive_depth_is_left_alone() {
143        let r = Representation::extrusion(square(), [0.0, 0.0, 1.0], 3.0);
144        assert_eq!(
145            r,
146            Representation::ExtrudedAreaSolid {
147                profile: square(),
148                direction: [0.0, 0.0, 1.0],
149                depth: 3.0,
150            }
151        );
152    }
153
154    #[test]
155    fn parametric_and_tessellated_map_to_different_ifc() {
156        let swept = Representation::extrusion(square(), [0.0, 0.0, 1.0], 1.0);
157        assert!(swept.is_native_parametric());
158        assert_eq!(swept.ifc_representation_type(), "SweptSolid");
159        assert_eq!(swept.ifc_item(), "IfcExtrudedAreaSolid");
160
161        let mesh = Representation::TriangulatedFaceSet {
162            vertices: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
163            faces: vec![[0, 1, 2]],
164        };
165        assert!(!mesh.is_native_parametric());
166        assert_eq!(mesh.ifc_representation_type(), "Tessellation");
167        assert!(mesh.is_valid());
168    }
169
170    #[test]
171    fn malformed_geometry_is_caught_before_it_reaches_a_file() {
172        assert!(
173            !Representation::extrusion(vec![[0.0, 0.0], [1.0, 0.0]], [0.0, 0.0, 1.0], 1.0)
174                .is_valid()
175        );
176        assert!(!Representation::extrusion(square(), [0.0, 0.0, 0.0], 1.0).is_valid());
177        assert!(!Representation::extrusion(square(), [0.0, 0.0, 1.0], 0.0).is_valid());
178        assert!(!Representation::extrusion(square(), [0.0, 0.0, 1.0], f64::NAN).is_valid());
179        assert!(!Representation::ExtrudedAreaSolid {
180            profile: vec![[0.0, 0.0], [1.0, 0.0], [f64::INFINITY, 1.0]],
181            direction: [0.0, 0.0, 1.0],
182            depth: 1.0,
183        }
184        .is_valid());
185    }
186
187    #[test]
188    fn an_out_of_range_face_index_is_invalid() {
189        // The failure that writes a file no other application can open.
190        assert!(!Representation::TriangulatedFaceSet {
191            vertices: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
192            faces: vec![[0, 1, 7]],
193        }
194        .is_valid());
195        assert!(!Representation::TriangulatedFaceSet {
196            vertices: Vec::new(),
197            faces: vec![[0, 1, 2]],
198        }
199        .is_valid());
200    }
201}